use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
pub tools: Vec<String>,
pub resume_mode: String,
}
impl Config {
pub fn default_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not resolve home directory")?;
Ok(home.join(".carryover").join("config.json"))
}
pub fn load_or_default(path: &Path) -> Result<Config> {
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).context("parse config.json"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config {
tools: Vec::new(),
resume_mode: "ask".to_string(),
}),
Err(e) => Err(e).context("read config.json"),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"refusing to follow symlink at {}",
path.display()
));
}
}
if let Some(parent) = path.parent() {
create_owner_only_dir(parent).context("create config dir")?;
}
let json = serde_json::to_string_pretty(self).context("serialize config")?;
let dir = path
.parent()
.context("config path has no parent directory")?;
#[cfg(unix)]
let mut tmp = {
use std::os::unix::fs::PermissionsExt;
tempfile::Builder::new()
.permissions(std::fs::Permissions::from_mode(0o600))
.tempfile_in(dir)
.context("create temp config file")?
};
#[cfg(not(unix))]
let mut tmp = tempfile::NamedTempFile::new_in(dir).context("create temp config file")?;
use std::io::Write as _;
tmp.write_all(json.as_bytes())
.context("write temp config")?;
tmp.as_file_mut().sync_all().context("sync temp config")?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("{}", e))
.context("persist config atomic write")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.context("set config.json permissions")?;
}
Ok(())
}
}
#[cfg(unix)]
fn create_owner_only_dir(p: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(p)
}
#[cfg(not(unix))]
fn create_owner_only_dir(p: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(p)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_then_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("config.json");
let cfg = Config {
tools: vec!["claude".to_string(), "cursor".to_string()],
resume_mode: "ask".to_string(),
};
cfg.save(&p).unwrap();
let loaded = Config::load_or_default(&p).unwrap();
assert_eq!(loaded, cfg);
}
#[test]
fn load_or_default_returns_default_when_missing() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("does-not-exist.json");
let cfg = Config::load_or_default(&p).unwrap();
assert_eq!(cfg.resume_mode, "ask");
assert!(cfg.tools.is_empty());
}
#[cfg(unix)]
#[test]
fn save_sets_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("config.json");
Config::default().save(&p).unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn default_resume_mode_is_ask() {
let cfg = Config::default();
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("missing.json");
let loaded = Config::load_or_default(&p).unwrap();
assert_eq!(loaded.resume_mode, "ask");
drop(cfg);
}
#[cfg(unix)]
#[test]
fn save_rejects_symlinked_target() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.json");
let link = dir.path().join("config.json");
std::fs::write(&real, b"{}").unwrap();
symlink(&real, &link).unwrap();
let err = Config::default()
.save(&link)
.expect_err("symlinked config target must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("symlink"),
"expected symlink-rejection error, got: {msg}"
);
assert_eq!(std::fs::read(&real).unwrap(), b"{}");
}
}