Files
agent-manager/src/app.rs
T

233 lines
7.5 KiB
Rust

//! Application context: configuration, catalog, state, paths, logger and
//! shared flags.
use crate::catalog::Catalog;
use crate::cli::Cli;
use crate::config::{self, Config, Settings};
use crate::output::Logger;
use crate::state::StateStore;
use crate::theme::{self, Theme};
use anyhow::{anyhow, Context, Result};
use std::cell::Cell;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
#[derive(Debug)]
pub struct App {
pub cli: Cli,
pub config: Config,
pub config_origin: Option<PathBuf>,
pub paths: Paths,
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
/// Active output language (issue #74); mirrored in i18n::active() so the
/// REPL 'lang' command can switch it at runtime.
lang: Cell<crate::i18n::Lang>,
/// Active output color theme (switchable at runtime with the REPL
/// 'theme <name>' command).
theme: Cell<&'static Theme>,
}
#[derive(Debug, Clone)]
pub struct Paths {
pub install_dir: PathBuf,
pub bin_dir: PathBuf,
pub log_dir: PathBuf,
pub state_file: PathBuf,
pub probe_cache_file: PathBuf,
pub config_dir: Option<PathBuf>,
}
impl Paths {
pub fn from_settings(settings: &Settings) -> Self {
let install_dir = settings
.install_dir
.as_deref()
.map(config::expand_path)
.unwrap_or_else(|| data_dir().join("agents"));
let log_dir = settings
.log_dir
.as_deref()
.map(config::expand_path)
.unwrap_or_else(|| state_dir().join("logs"));
let bin_dir = install_dir.join("bin");
let state_file = state_dir().join("state.json");
let probe_cache_file = state_dir().join("probe-cache.json");
let config_dir = config::user_config_dir();
Self {
install_dir,
bin_dir,
log_dir,
state_file,
probe_cache_file,
config_dir,
}
}
/// Create every directory the manager needs.
pub fn ensure(&self) -> Result<()> {
std::fs::create_dir_all(&self.install_dir)
.with_context(|| format!("cannot create {}", self.install_dir.display()))?;
std::fs::create_dir_all(&self.bin_dir)?;
std::fs::create_dir_all(&self.log_dir)?;
Ok(())
}
}
impl App {
pub fn from_cli(cli: Cli) -> Result<Self> {
let loaded = config::load(&cli)?;
let paths = Paths::from_settings(&loaded.config.settings);
let log = Logger::new(paths.log_dir.clone(), cli.verbose, cli.quiet, cli.no_color);
let state = StateStore::new(paths.state_file.clone());
let catalog = Catalog::new(
loaded.config.agents.clone(),
loaded.config.aliases.clone(),
loaded.config.groups.clone(),
);
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
let lang = crate::i18n::resolve(cli.lang);
crate::i18n::set_lang(lang);
Ok(Self {
cli,
config: loaded.config,
config_origin: loaded.origin,
paths,
catalog,
state,
log,
lang: Cell::new(lang),
theme: Cell::new(theme),
})
}
pub fn dry_run(&self) -> bool {
self.cli.dry_run
}
pub fn json(&self) -> bool {
self.cli.json
}
pub fn yes(&self) -> bool {
self.cli.yes
}
pub fn color(&self) -> bool {
!self.cli.no_color && std::io::stdout().is_terminal()
}
pub fn theme(&self) -> &'static Theme {
self.theme.get()
}
/// Active output language (issue #74).
pub fn lang(&self) -> crate::i18n::Lang {
self.lang.get()
}
/// Switch the active language (REPL 'lang' command); the i18n global is
/// kept in sync so tr()/tr_fmt! follow immediately.
pub fn set_lang(&self, lang: crate::i18n::Lang) {
self.lang.set(lang);
crate::i18n::set_lang(lang);
}
/// Switch the active theme (used by the REPL 'theme <name>' command).
pub fn set_theme(&self, theme: &'static Theme) {
self.theme.set(theme);
}
/// Directory of the event journal (the state directory).
pub fn events_dir(&self) -> PathBuf {
self.paths
.state_file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.paths.state_file.clone())
}
/// Append an event to the journal; failures are never fatal (logged at
/// verbose level only). The plugins subscribed to the event kind are
/// then fired (issue #75) — non-blocking, never fatal either.
pub fn emit(&self, event: &crate::events::Event) {
if let Err(e) = crate::events::append(&self.events_dir(), event) {
self.log.verbose(&format!("cannot write event journal: {e:#}"));
}
crate::plugins::dispatch(self, event);
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes {
return Ok(true);
}
eprint!("{question} [y/N] ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
let answer = line.trim().to_lowercase();
Ok(matches!(answer.as_str(), "y" | "yes" | "o" | "oui" | "true"))
}
}
/// Resolve the output color theme: the --theme flag wins, then the
/// configuration entry, then the default theme. An unknown --theme value is
/// an error; an unknown configured value silently falls back to the default.
fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> {
if let Some(name) = flag {
return theme::find(name).ok_or_else(|| {
anyhow!(
"unknown theme '{name}' — available: {}",
theme::names().join(", ")
)
});
}
Ok(configured
.and_then(theme::find)
.unwrap_or_else(theme::default_theme))
}
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
/// mostly used by tests).
pub fn data_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENT_MANAGER_DATA") {
return PathBuf::from(d);
}
if cfg!(windows) {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("share")));
return base
.map(|b| b.join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"));
}
if cfg!(target_os = "macos") {
return config::home_dir()
.map(|h| h.join("Library").join("Application Support").join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"));
}
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("share")));
base.map(|b| b.join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"))
}
/// Root directory for runtime state (logs, state file).
pub fn state_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENT_MANAGER_STATE") {
return PathBuf::from(d);
}
if cfg!(target_os = "linux") {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("state")));
if let Some(b) = base {
return b.join("agent-manager");
}
}
data_dir()
}