v0.4.1 : Phase 1 complete (17/17) - dashboard TUI (#33), favoris/notes/tags (#39), profils d'environnement (#40), completions dynamiques + man pages (#41), packaging officiel + CI (#42), self-update Gitea, state v3, 212 tests
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
//! profile: environment profiles (issue #40) — a named set of
|
||||
//! agent + env + args, defined in the config, applied with
|
||||
//! 'am start <agent> --profile dev'.
|
||||
|
||||
use super::*;
|
||||
use crate::cli::ProfileCmd;
|
||||
use crate::config::Profile;
|
||||
use crate::output::{boxed_table, print_json};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProfileJson {
|
||||
name: String,
|
||||
agent: Option<String>,
|
||||
env: BTreeMap<String, String>,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn run(app: &App, cmd: &ProfileCmd) -> Result<i32> {
|
||||
match cmd {
|
||||
ProfileCmd::List => run_list(app),
|
||||
ProfileCmd::Show { name } => run_show(app, name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_list(app: &App) -> Result<i32> {
|
||||
if app.config.profiles.is_empty() {
|
||||
app.log.info(
|
||||
"no profiles defined — add a 'profiles:' section to your config, e.g.:\n\
|
||||
profiles:\n\
|
||||
\x20 dev:\n\
|
||||
\x20 agent: claude-code\n\
|
||||
\x20 env: {DEBUG: \"1\"}\n\
|
||||
\x20 args: [--model, sonnet]",
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
let rows: Vec<ProfileJson> = app
|
||||
.config
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|(name, p)| ProfileJson {
|
||||
name: name.clone(),
|
||||
agent: p.agent.clone(),
|
||||
env: p.env.clone(),
|
||||
args: p.args.clone(),
|
||||
})
|
||||
.collect();
|
||||
if app.json() {
|
||||
print_json(&rows);
|
||||
return Ok(0);
|
||||
}
|
||||
let mut table_rows = Vec::new();
|
||||
for r in &rows {
|
||||
table_rows.push(vec![
|
||||
r.name.clone(),
|
||||
r.agent.clone().unwrap_or_else(|| "—".to_string()),
|
||||
if r.env.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
r.env
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
},
|
||||
if r.args.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
r.args.join(" ")
|
||||
},
|
||||
]);
|
||||
}
|
||||
print!(
|
||||
"{}",
|
||||
boxed_table(
|
||||
vec!["PROFILE", "AGENT", "ENV", "ARGS"],
|
||||
table_rows,
|
||||
app.theme(),
|
||||
app.color(),
|
||||
)
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
pub fn run_show(app: &App, name: &str) -> Result<i32> {
|
||||
let profile = resolve(app, name)?;
|
||||
if app.json() {
|
||||
print_json(&ProfileJson {
|
||||
name: name.to_string(),
|
||||
agent: profile.agent.clone(),
|
||||
env: profile.env.clone(),
|
||||
args: profile.args.clone(),
|
||||
});
|
||||
return Ok(0);
|
||||
}
|
||||
println!("{}", name);
|
||||
if let Some(a) = &profile.agent {
|
||||
println!(" agent {a}");
|
||||
}
|
||||
if profile.env.is_empty() {
|
||||
println!(" env —");
|
||||
} else {
|
||||
for (k, v) in &profile.env {
|
||||
println!(" env {k}={v}");
|
||||
}
|
||||
}
|
||||
if profile.args.is_empty() {
|
||||
println!(" args —");
|
||||
} else {
|
||||
println!(" args {}", profile.args.join(" "));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Resolve a profile by name; unknown names fail with the list of profiles.
|
||||
pub fn resolve<'a>(app: &'a App, name: &str) -> Result<&'a Profile> {
|
||||
app.config.profiles.get(name).ok_or_else(|| {
|
||||
let available = if app.config.profiles.is_empty() {
|
||||
"no profile is defined in the config".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"available profiles: {}",
|
||||
app.config.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
};
|
||||
anyhow!("unknown profile '{name}' — {available}")
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a profile to launch parameters: its args are appended and its env
|
||||
/// entries override the base ones (the profile wins — it is the most
|
||||
/// specific input at launch time).
|
||||
pub fn apply(
|
||||
app: &App,
|
||||
name: Option<&str>,
|
||||
base_args: &[String],
|
||||
base_env: &BTreeMap<String, String>,
|
||||
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
|
||||
let Some(name) = name else {
|
||||
return Ok((base_args.to_vec(), base_env.clone()));
|
||||
};
|
||||
let profile = resolve(app, name)?;
|
||||
let mut args = base_args.to_vec();
|
||||
args.extend(profile.args.iter().cloned());
|
||||
let mut env = base_env.clone();
|
||||
env.extend(profile.env.clone());
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// The default agent declared by a profile, if any.
|
||||
pub fn default_agent<'a>(app: &'a App, name: Option<&str>) -> Result<Option<&'a str>> {
|
||||
match name {
|
||||
None => Ok(None),
|
||||
Some(n) => Ok(resolve(app, n)?.agent.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn cfg_with_profiles() -> App {
|
||||
// The TempDir guard would delete the directory when this helper
|
||||
// returns; leak it on purpose so the App keeps a live state dir.
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nprofiles:\n dev:\n agent: claude-code\n env:\n DEBUG: \"1\"\n LOG_LEVEL: debug\n args: [--model, sonnet]\n prod:\n env:\n LOG_LEVEL: info\nagents: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
let mut app = App::from_cli(cli).unwrap();
|
||||
let mut p = app.paths.clone();
|
||||
p.install_dir = dir.join("agents");
|
||||
p.bin_dir = dir.join("agents").join("bin");
|
||||
p.log_dir = dir.join("logs");
|
||||
p.state_file = dir.join("state.json");
|
||||
p.probe_cache_file = dir.join("probe-cache.json");
|
||||
p.config_dir = Some(dir.join("config"));
|
||||
app.paths = p;
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_merges_args_and_env() {
|
||||
let app = cfg_with_profiles();
|
||||
let (args, env) = apply(&app, Some("dev"), &["--base".to_string()], &BTreeMap::from([("KEEP".into(), "1".into())]))
|
||||
.unwrap();
|
||||
assert_eq!(args, vec!["--base", "--model", "sonnet"]);
|
||||
assert_eq!(env.get("DEBUG").map(String::as_str), Some("1"));
|
||||
assert_eq!(env.get("KEEP").map(String::as_str), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_lists_available() {
|
||||
let app = cfg_with_profiles();
|
||||
let err = resolve(&app, "staging").unwrap_err().to_string();
|
||||
assert!(err.contains("unknown profile 'staging'"), "{err}");
|
||||
assert!(err.contains("dev") && err.contains("prod"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_profile_is_noop() {
|
||||
let app = cfg_with_profiles();
|
||||
let (args, env) = apply(&app, None, &["x".to_string()], &BTreeMap::new()).unwrap();
|
||||
assert_eq!(args, vec!["x"]);
|
||||
assert!(env.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user