use clap::Subcommand;
use serde::Serialize;
use tokf::history::{
self, HistoryConfig, OutputConfig, ShimsConfig, SyncConfig, TokfOutputSection,
TokfProjectConfig, TokfShimsSection, TokfSyncSection, global_config_path, load_project_config,
local_config_path, project_root_for, save_project_config,
};
#[derive(Subcommand)]
pub enum ConfigAction {
Show {
#[arg(long)]
json: bool,
},
Get {
key: String,
},
Set {
key: String,
value: String,
#[arg(long)]
local: bool,
},
Print {
#[arg(long, conflicts_with = "local")]
global: bool,
#[arg(long, conflicts_with = "global")]
local: bool,
},
Path,
}
pub fn run_config_action(action: &ConfigAction) -> i32 {
match action {
ConfigAction::Show { json } => cmd_config_show(*json),
ConfigAction::Get { key } => cmd_config_get(key),
ConfigAction::Set { key, value, local } => cmd_config_set(key, value, *local),
ConfigAction::Print { global, local } => cmd_config_print(*global, *local),
ConfigAction::Path => cmd_config_path(),
}
}
const KNOWN_KEYS: &[&str] = &[
"history.retention",
"output.show_indicator",
"shims.enabled",
"sync.auto_sync_threshold",
"sync.upload_stats",
];
fn print_known_keys() {
eprintln!("[tokf] known config keys:");
for key in KNOWN_KEYS {
eprintln!(" {key}");
}
}
#[derive(Serialize)]
struct ConfigEntry {
key: String,
value: Option<String>,
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
file: Option<String>,
}
fn cmd_config_show(json: bool) -> i32 {
let cwd = std::env::current_dir().unwrap_or_default();
let project_root = project_root_for(&cwd);
let global_path = global_config_path();
let local_path = local_config_path(&project_root);
let entries = collect_config_entries(global_path.as_deref(), &local_path, &project_root);
if json {
crate::output::print_json(&entries);
} else {
println!("tokf configuration:");
for entry in &entries {
let source_display = match entry.source.as_str() {
"default" => "(default)".to_string(),
"local" => format!(
"(local: {})",
entry.file.as_deref().unwrap_or(".tokf/config.toml")
),
"global" => format!(
"(global: {})",
entry.file.as_deref().unwrap_or("config.toml")
),
other => format!("({other})"),
};
let display_value = entry.value.as_deref().unwrap_or("(not set)");
println!(" {} = {display_value} {source_display}", entry.key);
}
}
0
}
#[allow(clippy::too_many_lines)]
fn collect_config_entries(
global_path: Option<&std::path::Path>,
local_path: &std::path::Path,
project_root: &std::path::Path,
) -> Vec<ConfigEntry> {
let history = HistoryConfig::load_from(Some(project_root), global_path);
let shims = ShimsConfig::load_from(global_path);
let sync = SyncConfig::load_from(Some(project_root), global_path);
let local_cfg = local_path
.is_file()
.then(|| load_project_config(local_path));
let global_cfg = global_path.filter(|p| p.is_file()).map(load_project_config);
let mut entries = Vec::new();
let src = |has_field: fn(&TokfProjectConfig) -> bool| {
find_source(
local_cfg.as_ref(),
local_path,
global_cfg.as_ref(),
global_path,
has_field,
)
};
let (ret_source, ret_file) = src(|c| c.history.as_ref().and_then(|h| h.retention).is_some());
entries.push(ConfigEntry {
key: "history.retention".to_string(),
value: Some(history.retention_count.to_string()),
source: ret_source,
file: ret_file,
});
let (shims_source, shims_file) = if global_cfg
.as_ref()
.is_some_and(|c| c.shims.as_ref().and_then(|s| s.enabled).is_some())
{
(
"global".to_string(),
global_path.map(|p| p.display().to_string()),
)
} else {
("default".to_string(), None)
};
entries.push(ConfigEntry {
key: "shims.enabled".to_string(),
value: Some(shims.enabled.to_string()),
source: shims_source,
file: shims_file,
});
let env_indicator = std::env::var("TOKF_SHOW_INDICATOR")
.ok()
.and_then(|v| v.parse::<bool>().ok());
let (output_val, output_source, output_file) = env_indicator.map_or_else(
|| {
let output = OutputConfig::load_from(Some(project_root), global_path);
let (s, f) = src(|c| c.output.as_ref().and_then(|o| o.show_indicator).is_some());
(output.show_indicator, s, f)
},
|b| {
(
b,
"env".to_string(),
Some("TOKF_SHOW_INDICATOR".to_string()),
)
},
);
entries.push(ConfigEntry {
key: "output.show_indicator".to_string(),
value: Some(output_val.to_string()),
source: output_source,
file: output_file,
});
let (thresh_source, thresh_file) = src(|c| {
c.sync
.as_ref()
.and_then(|s| s.auto_sync_threshold)
.is_some()
});
entries.push(ConfigEntry {
key: "sync.auto_sync_threshold".to_string(),
value: Some(sync.auto_sync_threshold.to_string()),
source: thresh_source,
file: thresh_file,
});
let (stats_source, stats_file) =
src(|c| c.sync.as_ref().and_then(|s| s.upload_usage_stats).is_some());
entries.push(ConfigEntry {
key: "sync.upload_stats".to_string(),
value: sync.upload_usage_stats.map(|b| b.to_string()),
source: stats_source,
file: stats_file,
});
entries
}
fn find_source(
local_cfg: Option<&TokfProjectConfig>,
local_path: &std::path::Path,
global_cfg: Option<&TokfProjectConfig>,
global_path: Option<&std::path::Path>,
has_field: fn(&TokfProjectConfig) -> bool,
) -> (String, Option<String>) {
if local_cfg.is_some_and(has_field) {
return ("local".to_string(), Some(local_path.display().to_string()));
}
if global_cfg.is_some_and(has_field) {
return (
"global".to_string(),
global_path.map(|p| p.display().to_string()),
);
}
("default".to_string(), None)
}
fn cmd_config_get(key: &str) -> i32 {
let cwd = std::env::current_dir().unwrap_or_default();
let project_root = project_root_for(&cwd);
match key {
"history.retention" => {
let config = HistoryConfig::load(Some(&project_root));
println!("{}", config.retention_count);
}
"output.show_indicator" => {
let config = OutputConfig::load(Some(&project_root));
println!("{}", config.show_indicator);
}
"shims.enabled" => {
let config = ShimsConfig::load(Some(&project_root));
println!("{}", config.enabled);
}
"sync.auto_sync_threshold" => {
let config = SyncConfig::load(Some(&project_root));
println!("{}", config.auto_sync_threshold);
}
"sync.upload_stats" => {
let config = SyncConfig::load(Some(&project_root));
if let Some(v) = config.upload_usage_stats {
println!("{v}");
} else {
return 1;
}
}
_ => {
eprintln!("[tokf] unknown config key: {key}");
print_known_keys();
return 1;
}
}
0
}
#[allow(clippy::too_many_lines)]
fn cmd_config_set(key: &str, value: &str, local: bool) -> i32 {
let target_path = if local {
let cwd = std::env::current_dir().unwrap_or_default();
let project_root = project_root_for(&cwd);
local_config_path(&project_root)
} else {
let Some(p) = global_config_path() else {
eprintln!("[tokf] cannot determine config directory");
return 1;
};
p
};
match key {
"history.retention" => set_parsed_field(
&target_path,
key,
value,
"a non-negative integer",
|cfg, n| {
cfg.history
.get_or_insert(history::TokfHistorySection { retention: None })
.retention = Some(n);
},
),
"output.show_indicator" => {
set_parsed_field(&target_path, key, value, "true or false", |cfg, b: bool| {
cfg.output
.get_or_insert(TokfOutputSection {
show_indicator: None,
})
.show_indicator = Some(b);
})
}
"shims.enabled" => {
if local {
eprintln!(
"[tokf] shims.enabled is a global-only setting — \
use without --local"
);
return 1;
}
let rc = set_parsed_field(&target_path, key, value, "true or false", |cfg, b| {
cfg.shims
.get_or_insert(TokfShimsSection { enabled: None })
.enabled = Some(b);
});
if rc == 0
&& value == "false"
&& let Some(dir) = tokf::paths::shims_dir()
{
let _ = std::fs::remove_dir_all(dir);
}
rc
}
"sync.auto_sync_threshold" => set_parsed_field(
&target_path,
key,
value,
"a non-negative integer",
|cfg, n| {
cfg.sync
.get_or_insert(TokfSyncSection {
auto_sync_threshold: None,
upload_usage_stats: None,
})
.auto_sync_threshold = Some(n);
},
),
"sync.upload_stats" => set_upload_stats(&target_path, value),
_ => {
eprintln!("[tokf] unknown config key: {key}");
print_known_keys();
1
}
}
}
fn set_parsed_field<T: std::str::FromStr>(
path: &std::path::Path,
key: &str,
value: &str,
type_hint: &str,
apply: fn(&mut TokfProjectConfig, T),
) -> i32 {
let Ok(parsed) = value.parse::<T>() else {
eprintln!("[tokf] invalid value for {key}: expected {type_hint}");
return 1;
};
let mut config = load_project_config(path);
apply(&mut config, parsed);
if let Err(e) = save_project_config(path, &config) {
eprintln!("[tokf] failed to write config: {e:#}");
return 1;
}
0
}
fn set_upload_stats(path: &std::path::Path, value: &str) -> i32 {
let Ok(b) = value.parse::<bool>() else {
eprintln!("[tokf] invalid value for sync.upload_stats: expected true or false");
return 1;
};
if let Err(e) = history::save_upload_stats_to_path(path, b) {
eprintln!("[tokf] failed to write config: {e:#}");
return 1;
}
0
}
fn cmd_config_print(global: bool, local: bool) -> i32 {
let path = if local {
let cwd = std::env::current_dir().unwrap_or_default();
let project_root = project_root_for(&cwd);
local_config_path(&project_root)
} else if global {
let Some(p) = global_config_path() else {
eprintln!("[tokf] cannot determine global config directory");
return 1;
};
p
} else {
let cwd = std::env::current_dir().unwrap_or_default();
let project_root = project_root_for(&cwd);
let local_path = local_config_path(&project_root);
if local_path.is_file() {
local_path
} else {
let Some(p) = global_config_path() else {
eprintln!("[tokf] no config file found");
return 1;
};
p
}
};
match std::fs::read_to_string(&path) {
Ok(content) => {
print!("{content}");
0
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("[tokf] config file not found: {}", path.display());
1
}
Err(e) => {
eprintln!("[tokf] error reading {}: {e}", path.display());
1
}
}
}
fn cmd_config_path() -> i32 {
let cwd = std::env::current_dir().unwrap_or_default();
let project_root = project_root_for(&cwd);
let global = global_config_path();
let local = local_config_path(&project_root);
print_path_line("global", global.as_deref());
print_path_line("local", Some(&local));
0
}
fn print_path_line(label: &str, path: Option<&std::path::Path>) {
if let Some(p) = path {
let status = if p.exists() { "exists" } else { "not found" };
println!("{label:7} {} ({status})", p.display());
} else {
println!("{label:7} (unavailable)");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn collect_config_entries_defaults() {
let dir = TempDir::new().unwrap();
let local = dir.path().join(".tokf/config.toml");
let entries = collect_config_entries(None, &local, dir.path());
assert_eq!(entries.len(), 5);
assert_eq!(entries[0].key, "history.retention");
assert_eq!(entries[0].value.as_deref(), Some("10"));
assert_eq!(entries[0].source, "default");
}
#[test]
fn collect_config_entries_from_local() {
let dir = TempDir::new().unwrap();
let tokf_dir = dir.path().join(".tokf");
std::fs::create_dir_all(&tokf_dir).unwrap();
let local = tokf_dir.join("config.toml");
std::fs::write(&local, "[history]\nretention = 42\n").unwrap();
let entries = collect_config_entries(None, &local, dir.path());
assert_eq!(entries[0].value.as_deref(), Some("42"));
assert_eq!(entries[0].source, "local");
}
#[test]
fn collect_config_entries_from_global() {
let dir = TempDir::new().unwrap();
let global = dir.path().join("global_config.toml");
std::fs::write(&global, "[sync]\nauto_sync_threshold = 200\n").unwrap();
let local = dir.path().join("nonexistent/.tokf/config.toml");
let entries = collect_config_entries(Some(&global), &local, dir.path());
assert_eq!(entries[3].value.as_deref(), Some("200"));
assert_eq!(entries[3].source, "global");
}
#[test]
fn known_keys_are_valid() {
assert!(KNOWN_KEYS.contains(&"history.retention"));
assert!(KNOWN_KEYS.contains(&"shims.enabled"));
assert!(KNOWN_KEYS.contains(&"sync.auto_sync_threshold"));
assert!(KNOWN_KEYS.contains(&"sync.upload_stats"));
}
fn set_retention(path: &std::path::Path, value: &str) -> i32 {
set_parsed_field(
path,
"history.retention",
value,
"a non-negative integer",
|cfg, n| {
cfg.history
.get_or_insert(history::TokfHistorySection { retention: None })
.retention = Some(n);
},
)
}
fn set_sync_threshold(path: &std::path::Path, value: &str) -> i32 {
set_parsed_field(
path,
"sync.auto_sync_threshold",
value,
"a non-negative integer",
|cfg, n| {
cfg.sync
.get_or_insert(TokfSyncSection {
auto_sync_threshold: None,
upload_usage_stats: None,
})
.auto_sync_threshold = Some(n);
},
)
}
#[test]
fn set_retention_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(set_retention(&path, "25"), 0);
let cfg = load_project_config(&path);
assert_eq!(cfg.history.unwrap().retention, Some(25));
}
#[test]
fn set_retention_invalid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(set_retention(&path, "abc"), 1);
}
#[test]
fn set_sync_threshold_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(set_sync_threshold(&path, "50"), 0);
let cfg = load_project_config(&path);
assert_eq!(cfg.sync.unwrap().auto_sync_threshold, Some(50));
}
#[test]
fn set_upload_stats_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(set_upload_stats(&path, "true"), 0);
let cfg = load_project_config(&path);
assert_eq!(cfg.sync.unwrap().upload_usage_stats, Some(true));
}
#[test]
fn set_upload_stats_invalid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(set_upload_stats(&path, "yes"), 1);
}
#[test]
fn set_shims_enabled_valid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(
set_parsed_field(
&path,
"shims.enabled",
"false",
"true or false",
|cfg, b| {
cfg.shims
.get_or_insert(TokfShimsSection { enabled: None })
.enabled = Some(b);
}
),
0
);
let cfg = load_project_config(&path);
assert_eq!(cfg.shims.unwrap().enabled, Some(false));
}
#[test]
fn set_shims_enabled_invalid() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
assert_eq!(
set_parsed_field(&path, "shims.enabled", "yes", "true or false", |cfg, b| {
cfg.shims
.get_or_insert(TokfShimsSection { enabled: None })
.enabled = Some(b);
}),
1
);
}
#[test]
fn collect_config_entries_shims_default() {
let dir = TempDir::new().unwrap();
let local = dir.path().join(".tokf/config.toml");
let entries = collect_config_entries(None, &local, dir.path());
let shims_entry = entries.iter().find(|e| e.key == "shims.enabled").unwrap();
assert_eq!(shims_entry.value.as_deref(), Some("true"));
assert_eq!(shims_entry.source, "default");
}
#[test]
fn set_preserves_existing_fields() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[history]\nretention = 30\n").unwrap();
set_sync_threshold(&path, "200");
let cfg = load_project_config(&path);
assert_eq!(cfg.history.unwrap().retention, Some(30));
assert_eq!(cfg.sync.unwrap().auto_sync_threshold, Some(200));
}
}