use std::fs;
use std::path::PathBuf;
use serde::Deserialize;
use crate::models::ApprovalMode;
pub const DEFAULT_MOBILE_WIDTH: u16 = 140;
pub const DEFAULT_REFRESH_SECONDS: u64 = 2;
#[derive(Debug, Clone, Default)]
pub struct Config {
pub ntfy: Option<NtfyConfig>,
pub thresholds: Thresholds,
pub notifications: NotificationsConfig,
pub model: ModelConfig,
pub new_agent: NewAgentConfig,
pub approval_mode: ApprovalMode,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NtfyConfig {
pub url: String,
pub user: Option<String>,
pub token: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Thresholds {
pub mobile_width: u16,
pub refresh_seconds: u64,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
mobile_width: DEFAULT_MOBILE_WIDTH,
refresh_seconds: DEFAULT_REFRESH_SECONDS,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NotificationsConfig {
pub terminal_bundle: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ModelConfig {
pub context_window: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct NewAgentConfig {
pub provider: NewAgentProvider,
pub command: String,
pub window_name: String,
}
impl Default for NewAgentConfig {
fn default() -> Self {
let provider = NewAgentProvider::Claude;
Self {
provider,
command: provider.default_command().to_string(),
window_name: "agent-{provider}-{cwd_basename}".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NewAgentProvider {
Claude,
Codex,
}
impl NewAgentProvider {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"claude" | "cc" | "claude-code" => Some(NewAgentProvider::Claude),
"codex" | "cx" => Some(NewAgentProvider::Codex),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
NewAgentProvider::Claude => "claude",
NewAgentProvider::Codex => "codex",
}
}
pub fn default_command(self) -> &'static str {
match self {
NewAgentProvider::Claude => "claude",
NewAgentProvider::Codex => "codex",
}
}
}
#[derive(Debug, Default, Deserialize)]
struct DiskConfig {
#[serde(default)]
ntfy: Option<NtfyConfig>,
#[serde(default)]
thresholds: Option<DiskThresholds>,
#[serde(default)]
notifications: Option<DiskNotifications>,
#[serde(default)]
model: Option<DiskModel>,
#[serde(default)]
new_agent: Option<DiskNewAgent>,
#[serde(default)]
approval: Option<DiskApproval>,
}
#[derive(Debug, Default, Deserialize)]
struct DiskThresholds {
#[serde(default)]
mobile_width: Option<u16>,
#[serde(default)]
refresh_seconds: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
struct DiskNotifications {
#[serde(default)]
terminal_bundle: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct DiskModel {
#[serde(default)]
context_window: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
struct DiskNewAgent {
#[serde(default)]
provider: Option<String>,
#[serde(default)]
command: Option<String>,
#[serde(default)]
window_name: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct DiskApproval {
#[serde(default)]
mode: Option<String>,
}
impl Config {
pub fn load() -> Self {
match read_disk() {
Ok(disk) => disk.into(),
Err(e) => {
if !is_missing(&e) {
eprintln!("[warn] failed to read config: {e}; using defaults");
}
Config::default()
}
}
}
}
fn config_path() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config/triage/config.toml"))
}
fn read_disk() -> std::io::Result<DiskConfig> {
let path = config_path()
.ok_or_else(|| std::io::Error::other("HOME not set; cannot resolve config path"))?;
let bytes = fs::read(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path)?.permissions().mode();
if mode & 0o077 != 0 {
eprintln!(
"[warn] {} has permissive perms ({:o}); refusing to load. \
Run `chmod 600 {}` to fix.",
path.display(),
mode & 0o777,
path.display(),
);
return Ok(DiskConfig::default());
}
}
let text = std::str::from_utf8(&bytes)
.map_err(|e| std::io::Error::other(format!("config not utf-8: {e}")))?;
toml::from_str::<DiskConfig>(text)
.map_err(|e| std::io::Error::other(format!("config parse error: {e}")))
}
fn is_missing(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::PermissionDenied
&& e.to_string().contains("No such file")
}
impl From<DiskConfig> for Config {
fn from(d: DiskConfig) -> Self {
let mut cfg = Config {
ntfy: d.ntfy,
..Default::default()
};
if let Some(t) = d.thresholds {
if let Some(v) = t.mobile_width {
cfg.thresholds.mobile_width = v;
}
if let Some(v) = t.refresh_seconds {
cfg.thresholds.refresh_seconds = v;
}
}
if let Some(n) = d.notifications {
cfg.notifications.terminal_bundle = n.terminal_bundle;
}
if let Some(m) = d.model {
cfg.model.context_window = m.context_window;
}
if let Some(a) = d.new_agent {
let explicit_provider = a.provider.and_then(|p| {
let parsed = NewAgentProvider::parse(&p);
if parsed.is_none() {
eprintln!(
"[warn] unknown [new_agent].provider {:?}; using default",
p.trim()
);
}
parsed
});
if let Some(provider) = explicit_provider {
cfg.new_agent.provider = provider;
cfg.new_agent.command = provider.default_command().to_string();
}
if let Some(command) = a.command.map(|c| c.trim().to_string())
&& !command.is_empty()
{
if explicit_provider.is_none()
&& let Some(provider) = infer_new_agent_provider_from_command(&command)
{
cfg.new_agent.provider = provider;
}
cfg.new_agent.command = command;
}
if let Some(window_name) = a.window_name.map(|n| n.trim().to_string())
&& !window_name.is_empty()
{
cfg.new_agent.window_name = window_name;
}
}
if let Some(a) = d.approval
&& let Some(mode) = a.mode
{
match parse_approval_mode(&mode) {
Some(mode) => cfg.approval_mode = mode,
None => eprintln!(
"[warn] unknown [approval].mode {:?}; using default",
mode.trim()
),
}
}
cfg
}
}
fn parse_approval_mode(value: &str) -> Option<ApprovalMode> {
match value.trim().to_ascii_lowercase().as_str() {
"hook" => Some(ApprovalMode::Hook),
"tmux" => Some(ApprovalMode::Tmux),
_ => None,
}
}
fn infer_new_agent_provider_from_command(command: &str) -> Option<NewAgentProvider> {
let first = command.split_whitespace().next()?;
let binary = PathBuf::from(first);
let name = binary.file_name().and_then(|n| n.to_str()).unwrap_or(first);
NewAgentProvider::parse(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_agent_defaults_to_claude() {
let cfg = Config::default();
assert_eq!(cfg.new_agent.provider, NewAgentProvider::Claude);
assert_eq!(cfg.new_agent.command, "claude");
}
#[test]
fn new_agent_provider_sets_default_command() {
let disk: DiskConfig = toml::from_str(
r#"
[new_agent]
provider = "codex"
"#,
)
.unwrap();
let cfg = Config::from(disk);
assert_eq!(cfg.new_agent.provider, NewAgentProvider::Codex);
assert_eq!(cfg.new_agent.command, "codex");
}
#[test]
fn new_agent_command_overrides_provider_default() {
let disk: DiskConfig = toml::from_str(
r#"
[new_agent]
provider = "codex"
command = "codex --model gpt-5"
window_name = "cx-{cwd_basename}"
"#,
)
.unwrap();
let cfg = Config::from(disk);
assert_eq!(cfg.new_agent.provider, NewAgentProvider::Codex);
assert_eq!(cfg.new_agent.command, "codex --model gpt-5");
assert_eq!(cfg.new_agent.window_name, "cx-{cwd_basename}");
}
#[test]
fn new_agent_provider_can_be_inferred_from_command() {
let disk: DiskConfig = toml::from_str(
r#"
[new_agent]
command = "codex --model gpt-5"
"#,
)
.unwrap();
let cfg = Config::from(disk);
assert_eq!(cfg.new_agent.provider, NewAgentProvider::Codex);
assert_eq!(cfg.new_agent.command, "codex --model gpt-5");
}
#[test]
fn approval_mode_defaults_to_hook() {
let cfg = Config::default();
assert_eq!(cfg.approval_mode, ApprovalMode::Hook);
}
#[test]
fn approval_mode_can_be_configured() {
let disk: DiskConfig = toml::from_str(
r#"
[approval]
mode = "tmux"
"#,
)
.unwrap();
let cfg = Config::from(disk);
assert_eq!(cfg.approval_mode, ApprovalMode::Tmux);
}
}