use crate::*;
use carryctx::adapter::config::ConfigLoader;
use carryctx::adapter::xdg::XdgPaths;
use carryctx::application::runtime::InvocationContext;
use carryctx::error::{CarryCtxError, ExitCode};
use clap::Parser;
#[derive(Parser, Debug)]
pub enum ConfigCommand {
List {
#[arg(long)]
global: bool,
},
Get { key: String },
Set {
key: String,
value: String,
#[arg(long)]
global: bool,
#[arg(long = "cfg-project")]
cfg_project: bool,
#[arg(long)]
local: bool,
},
Unset {
key: String,
#[arg(long)]
global: bool,
#[arg(long = "cfg-project")]
cfg_project: bool,
#[arg(long)]
local: bool,
},
Validate,
Sources,
Path {
#[arg(long)]
global: bool,
#[arg(long = "cfg-project")]
cfg_project: bool,
},
}
#[derive(Parser, Debug)]
pub struct ConfigArgs {
#[command(subcommand)]
pub command: ConfigCommand,
}
pub fn handle_config(
args: &ConfigArgs,
ctx: &InvocationContext,
is_json: bool,
) -> Result<ExitCode, ExitCode> {
if let Some(result) = check_dry_run_envelope(
ctx,
&subcommand_label("config", &args.command),
&format!("config {:?}", args.command),
) {
return result;
}
let xdg = XdgPaths::new();
let work_dir = resolve_work_dir(ctx);
match &args.command {
ConfigCommand::List { global } => {
let cfg_path = if *global {
xdg.global_config()
} else {
work_dir.join(".carryctx").join("config.toml")
};
let content: Result<String, CarryCtxError> = if cfg_path.exists() {
std::fs::read_to_string(&cfg_path).map_err(|e| {
CarryCtxError::configuration_error(format!(
"Failed to read {}: {e}",
cfg_path.display()
))
})
} else {
Ok(String::new())
};
let data = content.map(|content| {
serde_json::json!({
"path": cfg_path.to_string_lossy(),
"content": content,
})
});
render_and_print("config.list", data, is_json, ctx.quiet)
}
ConfigCommand::Get { key } => {
let cfg_loader = ConfigLoader::new(xdg);
match cfg_loader.load(Some(work_dir)) {
Ok(config) => {
let value = lookup_config_value(&config, key);
let data = serde_json::json!({ "key": key, "value": value });
render_and_print("config.get", Ok(data), is_json, ctx.quiet)
}
Err(e) => {
render_and_print::<serde_json::Value>("config.get", Err(e), is_json, ctx.quiet)
}
}
}
ConfigCommand::Set {
key,
value,
global,
cfg_project,
local,
} => {
let outcome = set_config_value(
&xdg,
work_dir,
key,
value,
ScopeSelection {
global: *global,
cfg_project: *cfg_project,
local: *local,
},
);
match outcome {
Ok(path) => {
let data = serde_json::json!({
"path": path.to_string_lossy(),
"key": key,
"value": typed_json_preview(value),
});
render_and_print("config.set", Ok(data), is_json, ctx.quiet)
}
Err(e) => {
render_and_print::<serde_json::Value>("config.set", Err(e), is_json, ctx.quiet)
}
}
}
ConfigCommand::Unset {
key,
global,
cfg_project,
local,
} => {
let outcome = unset_config_value(
&xdg,
work_dir,
key,
ScopeSelection {
global: *global,
cfg_project: *cfg_project,
local: *local,
},
);
match outcome {
Ok((path, removed)) => {
let data = serde_json::json!({ "key": key, "removed": removed, "path": path.to_string_lossy() });
render_and_print("config.unset", Ok(data), is_json, ctx.quiet)
}
Err(e) => render_and_print::<serde_json::Value>(
"config.unset",
Err(e),
is_json,
ctx.quiet,
),
}
}
ConfigCommand::Validate => {
let cfg_loader = ConfigLoader::new(xdg);
let result = cfg_loader.load(Some(work_dir));
match result {
Ok(config) => {
let data = serde_json::json!({
"valid": true,
"project": config.project,
"sources": ["global", "project", "env"]
});
render_and_print("config.validate", Ok(data), is_json, ctx.quiet)
}
Err(e) => render_and_print::<serde_json::Value>(
"config.validate",
Err(e),
is_json,
ctx.quiet,
),
}
}
ConfigCommand::Sources => {
let sources = serde_json::json!([
{ "name": "global", "path": xdg.global_config().to_string_lossy() },
{ "name": "project", "path": work_dir.join(".carryctx/config.toml").to_string_lossy() },
{ "name": "env", "prefix": "CARRYCTX_" },
]);
render_and_print("config.sources", Ok(sources), is_json, ctx.quiet)
}
ConfigCommand::Path {
global,
cfg_project,
} => {
#[allow(clippy::if_same_then_else)]
let path = if *global {
xdg.global_config()
} else if *cfg_project {
work_dir.join(".carryctx").join("config.toml")
} else {
work_dir.join(".carryctx").join("config.toml")
};
let data = serde_json::json!({ "path": path.to_string_lossy() });
render_and_print("config.path", Ok(data), is_json, ctx.quiet)
}
}
}
struct ScopeSelection {
global: bool,
cfg_project: bool,
local: bool,
}
fn resolve_write_scope(
xdg: &XdgPaths,
work_dir: &std::path::Path,
scope: &ScopeSelection,
) -> Result<std::path::PathBuf, CarryCtxError> {
let selected: Vec<bool> = vec![scope.global, scope.cfg_project, scope.local];
let count = selected.iter().filter(|b| **b).count();
if count > 1 {
return Err(CarryCtxError::invalid_arguments(
"'config set'/'config unset' accepts exactly one write scope.",
)
.with_suggestions(["Use only one of --global, --cfg-project, or --local.".to_string()]));
}
if scope.local {
return Err(CarryCtxError::unsupported_operation(
"--local targets .carryctx/local.toml, which the configuration loader does not read yet.",
)
.with_suggestions([
"Use --cfg-project to write .carryctx/config.toml (shared via Git).".to_string(),
"Use --global to write the per-machine user configuration.".to_string(),
]));
}
if scope.global {
Ok(xdg.global_config())
} else if scope.cfg_project {
Ok(work_dir.join(".carryctx").join("config.toml"))
} else {
Err(CarryCtxError::invalid_arguments(
"No write scope given: 'config set'/'config unset' require --global or --cfg-project.",
)
.with_suggestions([
"--global → ~/.config/carryctx/config.toml (per machine)".to_string(),
"--cfg-project → <repo>/.carryctx/config.toml (shared via Git)".to_string(),
]))
}
}
fn parse_config_document(path: &std::path::Path) -> Result<toml_edit::DocumentMut, CarryCtxError> {
if !path.exists() {
return Ok(toml_edit::DocumentMut::new());
}
let raw = std::fs::read_to_string(path).map_err(|e| {
CarryCtxError::configuration_error(format!("Failed to read {}: {e}", path.display()))
})?;
raw.parse::<toml_edit::DocumentMut>().map_err(|e| {
CarryCtxError::configuration_error(format!("{} is not valid TOML: {e}", path.display()))
})
}
fn write_config_document(
path: &std::path::Path,
doc: &toml_edit::DocumentMut,
) -> Result<(), CarryCtxError> {
let serialized = doc.to_string();
serialized.parse::<toml_edit::DocumentMut>().map_err(|e| {
CarryCtxError::configuration_error(format!(
"Refusing to write {}: edited result is not valid TOML ({e})",
path.display()
))
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
CarryCtxError::configuration_error(format!(
"Failed to create directory {}: {e}",
parent.display()
))
})?;
}
std::fs::write(path, serialized).map_err(|e| {
CarryCtxError::configuration_error(format!("Failed to write {}: {e}", path.display()))
})
}
fn typed_toml_value(raw: &str) -> toml_edit::Value {
let trimmed = raw.trim();
match trimmed {
"true" => return toml_edit::Value::from(true),
"false" => return toml_edit::Value::from(false),
_ => {}
}
if !has_leading_zero_numeral(trimmed) {
if let Ok(int) = trimmed.parse::<i64>() {
return toml_edit::Value::from(int);
}
if let Some(float) = parse_toml_float(trimmed) {
return toml_edit::Value::from(float);
}
}
toml_edit::Value::from(raw)
}
fn has_leading_zero_numeral(s: &str) -> bool {
let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
if digits.len() <= 1 || !digits.starts_with('0') {
return false;
}
digits[1..].bytes().all(|b| b.is_ascii_digit())
}
fn parse_toml_float(trimmed: &str) -> Option<f64> {
if !trimmed.contains(['.', 'e', 'E']) {
return None;
}
trimmed.parse::<f64>().ok().filter(|f| f.is_finite())
}
fn typed_json_preview(raw: &str) -> serde_json::Value {
let mut doc = toml_edit::DocumentMut::new();
doc["v"] = toml_edit::Item::Value(typed_toml_value(raw));
toml::from_str::<toml::Value>(&doc.to_string())
.ok()
.and_then(|parsed| parsed.get("v").cloned())
.map(toml_value_to_json)
.unwrap_or_else(|| serde_json::Value::String(raw.to_string()))
}
fn toml_value_to_json(value: toml::Value) -> serde_json::Value {
match value {
toml::Value::Boolean(b) => serde_json::Value::Bool(b),
toml::Value::Integer(i) => serde_json::json!(i),
toml::Value::Float(f) => serde_json::json!(f),
toml::Value::String(s) => serde_json::Value::String(s),
toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
toml::Value::Array(items) => {
serde_json::Value::Array(items.into_iter().map(toml_value_to_json).collect())
}
toml::Value::Table(map) => {
let mut obj = serde_json::Map::new();
for (key, val) in map {
obj.insert(key, toml_value_to_json(val));
}
serde_json::Value::Object(obj)
}
}
}
fn table_for_key<'a>(
doc: &'a mut toml_edit::DocumentMut,
parts: &[&str],
) -> Result<&'a mut toml_edit::Table, CarryCtxError> {
let mut current = doc.as_table_mut();
for part in &parts[..parts.len() - 1] {
let entry = current
.entry(part)
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
if !entry.is_table() {
return Err(CarryCtxError::state_conflict(format!(
"Cannot descend into '{part}': it is already a scalar value."
)));
}
current = entry.as_table_mut().expect("checked as table");
}
Ok(current)
}
fn insert_config_key(
doc: &mut toml_edit::DocumentMut,
key: &str,
value: toml_edit::Value,
) -> Result<(), CarryCtxError> {
let parts: Vec<&str> = key.split('.').collect();
if parts.iter().any(|p| p.trim().is_empty()) {
return Err(CarryCtxError::invalid_arguments(format!(
"Invalid configuration key '{key}': empty path segment."
)));
}
let leaf = *parts.last().expect("non-empty split always has a last");
let table = table_for_key(doc, &parts)?;
if table
.get(leaf)
.map(|existing| existing.is_table())
.unwrap_or(false)
{
return Err(CarryCtxError::state_conflict(format!(
"Refusing to overwrite '{key}': it is a table with nested keys."
)));
}
table.insert(leaf, toml_edit::Item::Value(value));
Ok(())
}
fn remove_config_key(doc: &mut toml_edit::DocumentMut, key: &str) -> Result<bool, CarryCtxError> {
let parts: Vec<&str> = key.split('.').collect();
if parts.iter().any(|p| p.trim().is_empty()) {
return Err(CarryCtxError::invalid_arguments(format!(
"Invalid configuration key '{key}': empty path segment."
)));
}
let mut current = doc.as_table_mut();
for part in &parts[..parts.len() - 1] {
match current.get_mut(part).and_then(|item| item.as_table_mut()) {
Some(table) => current = table,
None => return Ok(false),
}
}
let leaf = *parts.last().expect("non-empty split always has a last");
Ok(current.remove(leaf).is_some())
}
fn set_config_value(
xdg: &XdgPaths,
work_dir: &std::path::Path,
key: &str,
value: &str,
scope: ScopeSelection,
) -> Result<std::path::PathBuf, CarryCtxError> {
let path = resolve_write_scope(xdg, work_dir, &scope)?;
let mut doc = parse_config_document(&path)?;
insert_config_key(&mut doc, key, typed_toml_value(value))?;
write_config_document(&path, &doc)?;
Ok(path)
}
fn unset_config_value(
xdg: &XdgPaths,
work_dir: &std::path::Path,
key: &str,
scope: ScopeSelection,
) -> Result<(std::path::PathBuf, bool), CarryCtxError> {
let path = resolve_write_scope(xdg, work_dir, &scope)?;
if !path.exists() {
return Ok((path, false));
}
let mut doc = parse_config_document(&path)?;
let removed = remove_config_key(&mut doc, key)?;
if removed {
write_config_document(&path, &doc)?;
}
Ok((path, removed))
}
fn lookup_config_value(
config: &carryctx::domain::config::CarryCtxConfig,
key: &str,
) -> serde_json::Value {
let mut cursor = match serde_json::to_value(config) {
Ok(value) => value,
Err(_) => return serde_json::Value::Null,
};
for part in key.split('.') {
match cursor.get(part) {
Some(next) => cursor = next.clone(),
None => return serde_json::Value::Null,
}
}
cursor
}
#[cfg(test)]
mod config_cli_tests {
use super::*;
fn doc_from(raw: &str) -> toml_edit::DocumentMut {
raw.parse::<toml_edit::DocumentMut>().expect("valid toml")
}
#[test]
fn typed_values_are_not_all_strings() {
assert!(matches!(
typed_toml_value("true"),
toml_edit::Value::Boolean(_)
));
assert!(matches!(
typed_toml_value("42"),
toml_edit::Value::Integer(_)
));
assert!(matches!(
typed_toml_value("1.5"),
toml_edit::Value::Float(_)
));
match typed_toml_value("4h") {
toml_edit::Value::String(formatted) => {
assert_eq!(formatted.value(), "4h");
}
other => panic!("'4h' must stay a string, got {other:?}"),
}
}
#[test]
fn dotted_keys_land_at_root_not_in_last_table() {
let mut doc = doc_from("[verification]\ncommands = [\"x\"]\n");
insert_config_key(&mut doc, "task.strict_completion", typed_toml_value("true"))
.expect("insert");
let out = doc.to_string();
let reparsed = out.parse::<toml_edit::DocumentMut>().expect("round trip");
assert_eq!(
reparsed["task"]["strict_completion"].as_bool(),
Some(true),
"typed boolean must survive the round trip:\n{out}"
);
assert_eq!(
reparsed["verification"]["commands"]
.as_array()
.map(|a| a.len()),
Some(1),
"existing table content must survive:\n{out}"
);
let verification_body = out
.split("[verification]")
.nth(1)
.expect("verification header present");
let verification_body = match verification_body.split_once('[') {
Some((before_next_table, _)) => before_next_table,
None => verification_body,
};
assert!(
!verification_body.contains("strict_completion"),
"dotted key leaked into [verification]:\n{out}"
);
}
#[test]
fn existing_tables_and_comments_survive_set() {
let mut doc =
doc_from("# header comment\n[task]\n# inner\nsingle_active_task_per_agent = true\n");
insert_config_key(
&mut doc,
"task.strict_completion",
typed_toml_value("false"),
)
.expect("insert");
let out = doc.to_string();
assert!(out.contains("# header comment"));
assert!(out.contains("# inner"));
assert_eq!(
doc["task"]["single_active_task_per_agent"].as_bool(),
Some(true)
);
}
#[test]
fn unset_removes_dotted_leaf_only() {
let mut doc =
doc_from("[task]\nstrict_completion = false\n\n[verification]\ncommands = []\n");
assert!(remove_config_key(&mut doc, "task.strict_completion").expect("remove"));
let out = doc.to_string();
assert!(!out.contains("strict_completion"));
assert!(out.contains("[verification]"), "unrelated tables stay");
assert!(!remove_config_key(&mut doc, "missing.key").expect("remove absent"));
}
#[test]
fn refusing_to_clobber_a_table() {
let mut doc = doc_from("[verification]\ncommands = []\n");
let err = insert_config_key(&mut doc, "verification", typed_toml_value("oops"));
assert!(err.is_err(), "must not replace a table with a scalar");
}
#[test]
fn multibyte_values_round_trip() {
let mut doc = doc_from("");
insert_config_key(
&mut doc,
"project.name",
typed_toml_value("项目「テスト」🚀"),
)
.expect("insert");
let out = doc.to_string();
let reparsed = out.parse::<toml_edit::DocumentMut>().expect("round trip");
assert_eq!(
reparsed["project"]["name"].as_str(),
Some("项目「テスト」🚀")
);
}
#[test]
fn lookup_walks_nested_keys_and_returns_null_for_unknowns() {
let config = carryctx::domain::config::CarryCtxConfig::default();
assert_eq!(
lookup_config_value(&config, "task.strict_completion"),
serde_json::json!(config.task.strict_completion)
);
assert_eq!(
lookup_config_value(&config, "git.main_branch"),
serde_json::json!(config.git.main_branch)
);
assert_eq!(
lookup_config_value(&config, "no.such.key"),
serde_json::Value::Null
);
assert_eq!(
lookup_config_value(&config, "proj.name"),
serde_json::Value::Null
);
}
#[test]
fn float_like_and_leading_zero_stay_strings_or_parse_cleanly() {
assert!(matches!(
typed_toml_value("0755"),
toml_edit::Value::String(_)
));
assert!(matches!(
typed_toml_value("-12"),
toml_edit::Value::Integer(_)
));
}
}