use anyhow::{Context, Result, anyhow, bail};
use bones_core::config::{EffectiveConfig, resolve_config};
use clap::{Args, Subcommand, ValueEnum};
use std::path::{Path, PathBuf};
use toml::Value;
use crate::output::OutputMode;
#[derive(Args, Debug)]
pub struct ConfigArgs {
#[command(subcommand)]
command: ConfigCommand,
}
#[derive(Subcommand, Debug)]
enum ConfigCommand {
Show(ShowArgs),
Set(SetArgs),
Unset(UnsetArgs),
}
#[derive(Args, Debug)]
struct ShowArgs {
#[arg(long, conflicts_with = "user")]
project: bool,
#[arg(long)]
user: bool,
}
#[derive(Args, Debug)]
struct SetArgs {
#[arg(long, default_value = "project")]
scope: ConfigScope,
key: String,
value: String,
}
#[derive(Args, Debug)]
struct UnsetArgs {
#[arg(long, default_value = "project")]
scope: ConfigScope,
key: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum ConfigScope {
Project,
User,
}
pub fn run_config(args: &ConfigArgs, project_root: &Path, output: OutputMode) -> Result<()> {
match &args.command {
ConfigCommand::Show(show) => run_show(show, project_root, output),
ConfigCommand::Set(set) => run_set(set, project_root, output),
ConfigCommand::Unset(unset) => run_unset(unset, project_root, output),
}
}
fn run_show(args: &ShowArgs, project_root: &Path, output: OutputMode) -> Result<()> {
if args.project {
let value = load_toml_table(&project_config_path(project_root))?;
print_toml_or_json(&value, output);
return Ok(());
}
if args.user {
let value = load_toml_table(&user_config_path()?)?;
print_toml_or_json(&value, output);
return Ok(());
}
let effective = resolve_config(project_root, output.is_json())?;
print_effective(&effective, output)?;
Ok(())
}
fn run_set(args: &SetArgs, project_root: &Path, output: OutputMode) -> Result<()> {
let path = match args.scope {
ConfigScope::Project => project_config_path(project_root),
ConfigScope::User => user_config_path()?,
};
let mut value = load_toml_table(&path)?;
apply_set(&mut value, args.scope, &args.key, &args.value)?;
write_toml_table(&path, &value)?;
render_mutation(output, "set", scope_label(args.scope), &args.key)?;
Ok(())
}
fn run_unset(args: &UnsetArgs, project_root: &Path, output: OutputMode) -> Result<()> {
let path = match args.scope {
ConfigScope::Project => project_config_path(project_root),
ConfigScope::User => user_config_path()?,
};
let mut value = load_toml_table(&path)?;
apply_unset(&mut value, args.scope, &args.key)?;
write_toml_table(&path, &value)?;
render_mutation(output, "unset", scope_label(args.scope), &args.key)?;
Ok(())
}
fn apply_set(root: &mut Value, scope: ConfigScope, key: &str, raw: &str) -> Result<()> {
let parsed = parse_value(scope, key, raw)?;
let (section, leaf) = split_known_key(scope, key)?;
let table = root
.as_table_mut()
.ok_or_else(|| anyhow!("Config root must be a TOML table"))?;
let section_entry = table
.entry(section.to_string())
.or_insert_with(|| Value::Table(toml::map::Map::new()));
let section_table = section_entry
.as_table_mut()
.ok_or_else(|| anyhow!("Section {section} must be a TOML table"))?;
section_table.insert(leaf.to_string(), parsed);
Ok(())
}
fn apply_unset(root: &mut Value, scope: ConfigScope, key: &str) -> Result<()> {
let (section, leaf) = split_known_key(scope, key)?;
let table = root
.as_table_mut()
.ok_or_else(|| anyhow!("Config root must be a TOML table"))?;
if let Some(section_entry) = table.get_mut(section)
&& let Some(section_table) = section_entry.as_table_mut()
{
section_table.remove(leaf);
if section_table.is_empty() {
table.remove(section);
}
}
Ok(())
}
fn split_known_key(scope: ConfigScope, key: &str) -> Result<(&str, &str)> {
let (section, leaf) = key
.split_once('.')
.ok_or_else(|| anyhow!("Key must use section.key format"))?;
let valid = match scope {
ConfigScope::Project => matches!(
(section, leaf),
("goals", "auto_complete")
| (
"search",
"semantic"
| "model"
| "duplicate_threshold"
| "related_threshold"
| "warn_on_create"
)
| ("triage", "feedback_learning")
| ("done", "require_reason")
),
ConfigScope::User => matches!((section, leaf), ("user", "output")),
};
if valid {
Ok((section, leaf))
} else {
bail!("Unsupported key `{key}` for {} scope", scope_label(scope));
}
}
fn parse_value(scope: ConfigScope, key: &str, raw: &str) -> Result<Value> {
let (section, leaf) = split_known_key(scope, key)?;
match (section, leaf) {
("search", "model") | ("user", "output") => Ok(Value::String(raw.to_string())),
("search", "duplicate_threshold" | "related_threshold") => {
let number: f64 = raw
.parse()
.with_context(|| format!("{key} expects a number"))?;
let toml_num = toml::Value::try_from(number)
.map_err(|_| anyhow!("{key} could not be represented as TOML number"))?;
Ok(toml_num)
}
_ => {
let value: bool = raw
.parse()
.with_context(|| format!("{key} expects true or false"))?;
Ok(Value::Boolean(value))
}
}
}
fn load_toml_table(path: &Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Table(toml::map::Map::new()));
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let value: Value =
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
if !value.is_table() {
bail!("{} must contain a top-level TOML table", path.display());
}
Ok(value)
}
fn write_toml_table(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let serialized = toml::to_string_pretty(value)?;
std::fs::write(path, serialized).with_context(|| format!("Failed to write {}", path.display()))
}
fn print_toml_or_json(value: &Value, output: OutputMode) {
match output {
OutputMode::Json => match serde_json::to_string_pretty(value) {
Ok(json) => println!("{json}"),
Err(_) => println!("{{}}"),
},
OutputMode::Text | OutputMode::Pretty => {
println!("{}", toml::to_string_pretty(value).unwrap_or_default());
}
}
}
fn print_effective(value: &EffectiveConfig, output: OutputMode) -> Result<()> {
match output {
OutputMode::Json => {
println!("{}", serde_json::to_string_pretty(value)?);
}
OutputMode::Text => {
println!("resolved_output={}", value.resolved_output);
println!("goals.auto_complete={}", value.project.goals.auto_complete);
println!("search.semantic={}", value.project.search.semantic);
println!("search.model={}", value.project.search.model);
println!(
"search.duplicate_threshold={}",
value.project.search.duplicate_threshold
);
println!(
"search.related_threshold={}",
value.project.search.related_threshold
);
println!(
"search.warn_on_create={}",
value.project.search.warn_on_create
);
println!(
"triage.feedback_learning={}",
value.project.triage.feedback_learning
);
println!("done.require_reason={}", value.project.done.require_reason);
if let Some(out) = &value.user.output {
println!("user.output={out}");
}
}
OutputMode::Pretty => {
println!("resolved_output = \"{}\"", value.resolved_output);
println!();
println!("[goals]");
println!("auto_complete = {}", value.project.goals.auto_complete);
println!();
println!("[search]");
println!("semantic = {}", value.project.search.semantic);
println!("model = \"{}\"", value.project.search.model);
println!(
"duplicate_threshold = {}",
value.project.search.duplicate_threshold
);
println!(
"related_threshold = {}",
value.project.search.related_threshold
);
println!("warn_on_create = {}", value.project.search.warn_on_create);
println!();
println!("[triage]");
println!(
"feedback_learning = {}",
value.project.triage.feedback_learning
);
println!();
println!("[done]");
println!("require_reason = {}", value.project.done.require_reason);
println!();
println!("[user]");
if let Some(out) = &value.user.output {
println!("output = \"{out}\"");
}
}
}
Ok(())
}
fn render_mutation(output: OutputMode, action: &str, scope: &str, key: &str) -> Result<()> {
match output {
OutputMode::Json => {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"action": action,
"scope": scope,
"key": key,
}))?
);
}
OutputMode::Text => {
println!("ok=true action={action} scope={scope} key={key}");
}
OutputMode::Pretty => {
println!("{} {} in {} config", action_to_title(action), key, scope);
}
}
Ok(())
}
fn action_to_title(action: &str) -> &'static str {
match action {
"set" => "Set",
"unset" => "Unset",
_ => "Updated",
}
}
fn project_config_path(project_root: &Path) -> PathBuf {
project_root.join(".bones/config.toml")
}
fn user_config_path() -> Result<PathBuf> {
let config_dir =
dirs::config_dir().ok_or_else(|| anyhow!("Unable to resolve user config directory"))?;
Ok(config_dir.join("bones/config.toml"))
}
const fn scope_label(scope: ConfigScope) -> &'static str {
match scope {
ConfigScope::Project => "project",
ConfigScope::User => "user",
}
}