agent-sync 0.5.0

Safely synchronize coding-agent sessions and memories over SSH
use crate::adapters::AgentKind;
use crate::core::ConflictStrategy;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    #[serde(default = "version")]
    version: u32,
    default_peer: Option<String>,
    conflict_strategy: Option<ConflictStrategy>,
    #[serde(default)]
    peers: BTreeMap<String, Peer>,
    #[serde(default)]
    agents: BTreeMap<String, Agent>,
}

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Peer {
    host: Option<String>,
    ssh: Option<String>,
    rsync: Option<String>,
    #[serde(default)]
    roots: BTreeMap<String, String>,
}

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Agent {
    local_root: Option<String>,
    conflict_strategy: Option<ConflictStrategy>,
}

fn version() -> u32 {
    1
}

pub struct Resolved {
    pub host: String,
    pub ssh: String,
    pub rsync: String,
    pub local_root: PathBuf,
    pub remote_root: String,
}

impl Config {
    pub fn load(explicit: Option<&Path>) -> Result<Self> {
        let path = explicit
            .map(PathBuf::from)
            .or_else(|| std::env::var_os("AGENT_SYNC_CONFIG").map(PathBuf::from))
            .or_else(|| dirs::config_dir().map(|path| path.join("agent-sync/config.toml")));
        let Some(path) = path else {
            return Ok(Self {
                version: 1,
                ..Self::default()
            });
        };
        if !path.exists() {
            if explicit.is_some() {
                bail!("configuration does not exist: {}", path.display());
            }
            return Ok(Self {
                version: 1,
                ..Self::default()
            });
        }
        let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
        let config: Self =
            toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
        if config.version != 1 {
            bail!("unsupported config version {}", config.version);
        }
        Ok(config)
    }

    pub fn resolve(
        &self,
        kind: AgentKind,
        peer_name: &str,
        local: Option<&Path>,
        remote: Option<&str>,
    ) -> Result<Resolved> {
        let key = kind.to_string();
        let peer = self.peers.get(peer_name);
        let host = peer
            .and_then(|p| p.host.clone())
            .unwrap_or_else(|| peer_name.to_owned());
        validate_host(&host)?;
        let local_root = self.local_root(kind, local)?;
        let default_remote = match kind {
            AgentKind::Codex => ".codex",
            AgentKind::Claude => ".claude",
            AgentKind::Opencode => ".local/share/opencode",
        };
        let remote_root = remote
            .map(str::to_owned)
            .or_else(|| peer.and_then(|p| p.roots.get(&key).cloned()))
            .unwrap_or_else(|| default_remote.to_owned());
        validate_remote_root(&remote_root)?;
        Ok(Resolved {
            host,
            ssh: peer
                .and_then(|p| p.ssh.clone())
                .unwrap_or_else(|| "/usr/bin/ssh".to_owned()),
            rsync: peer
                .and_then(|p| p.rsync.clone())
                .unwrap_or_else(|| "rsync".to_owned()),
            local_root,
            remote_root,
        })
    }

    pub fn peer_name(&self, command_line: Option<&str>) -> Result<String> {
        command_line
            .map(str::to_owned)
            .or_else(|| self.default_peer.clone())
            .filter(|peer| !peer.trim().is_empty())
            .context(
                "peer is required; pass <PEER> (for example: mini) or set default_peer = \"mini\" in the configuration file",
            )
    }

    pub fn conflict_strategy(&self, kind: AgentKind) -> ConflictStrategy {
        self.agents
            .get(&kind.to_string())
            .and_then(|agent| agent.conflict_strategy)
            .or(self.conflict_strategy)
            .unwrap_or_default()
    }

    pub fn local_root(&self, kind: AgentKind, command_line: Option<&Path>) -> Result<PathBuf> {
        let home = dirs::home_dir().context("cannot determine home directory")?;
        let default = match kind {
            AgentKind::Codex => home.join(".codex"),
            AgentKind::Claude => home.join(".claude"),
            AgentKind::Opencode => home.join(".local/share/opencode"),
        };
        let configured = self
            .agents
            .get(&kind.to_string())
            .and_then(|agent| agent.local_root.as_deref())
            .map(expand_home)
            .transpose()?;
        Ok(command_line
            .map(PathBuf::from)
            .or(configured)
            .unwrap_or(default))
    }

    pub fn infer_agent(&self, current_dir: &Path) -> Result<AgentKind> {
        let current = fs::canonicalize(current_dir)
            .with_context(|| format!("resolve current directory {}", current_dir.display()))?;
        let mut matches = Vec::new();
        for kind in AgentKind::all() {
            let root = self.local_root(kind, None)?;
            let Ok(root) = fs::canonicalize(&root) else {
                continue;
            };
            if current.starts_with(&root) {
                matches.push((root.components().count(), kind, root));
            }
        }
        matches.sort_by_key(|(depth, _, _)| *depth);
        let Some((depth, kind, _)) = matches.pop() else {
            bail!(
                "agent is required outside a configured agent directory; pass codex, claude, or opencode"
            );
        };
        if matches
            .last()
            .is_some_and(|(other_depth, _, _)| *other_depth == depth)
        {
            bail!(
                "current directory matches multiple configured agent roots; pass the agent explicitly"
            );
        }
        Ok(kind)
    }
}

fn expand_home(value: &str) -> Result<PathBuf> {
    if value == "~" {
        return dirs::home_dir().context("cannot expand ~");
    }
    if let Some(suffix) = value.strip_prefix("~/") {
        return Ok(dirs::home_dir().context("cannot expand ~")?.join(suffix));
    }
    Ok(PathBuf::from(value))
}

fn validate_host(value: &str) -> Result<()> {
    if value.is_empty()
        || !value
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || "_.@-".contains(c))
    {
        bail!("unsafe SSH host or alias: {value:?}");
    }
    Ok(())
}

fn validate_remote_root(value: &str) -> Result<()> {
    if value.is_empty() || value.contains(['\n', '\r', '\0']) {
        bail!("unsafe remote root");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn host_validation() {
        assert!(validate_host("user@mini-1").is_ok());
        assert!(validate_host("mini;false").is_err());
    }

    #[test]
    fn conflict_strategy_defaults_to_ask_and_can_be_configured() {
        let default = Config::default();
        assert_eq!(
            default.conflict_strategy(AgentKind::Claude),
            ConflictStrategy::Ask
        );

        let configured: Config = toml::from_str(
            r#"
            version = 1
            [agents.claude]
            conflict_strategy = "local"
            "#,
        )
        .unwrap();
        assert_eq!(
            configured.conflict_strategy(AgentKind::Claude),
            ConflictStrategy::Local
        );

        let global: Config = toml::from_str(
            r#"
            version = 1
            conflict_strategy = "remote"
            "#,
        )
        .unwrap();
        assert_eq!(
            global.conflict_strategy(AgentKind::Claude),
            ConflictStrategy::Remote
        );

        let legacy: Config = toml::from_str(
            r#"
            version = 1
            [agents.claude]
            conflict_strategy = "merge"
            "#,
        )
        .unwrap();
        assert_eq!(
            legacy.conflict_strategy(AgentKind::Claude),
            ConflictStrategy::Ask
        );
    }

    #[test]
    fn command_line_peer_overrides_configured_default() {
        let configured: Config = toml::from_str(
            r#"
            version = 1
            default_peer = "mini"
            "#,
        )
        .unwrap();
        assert_eq!(configured.peer_name(None).unwrap(), "mini");
        assert_eq!(configured.peer_name(Some("dev")).unwrap(), "dev");
        assert!(Config::default().peer_name(None).is_err());
    }

    #[test]
    fn infers_agent_from_configured_root_and_descendants() {
        let temp = tempfile::tempdir().unwrap();
        let claude = temp.path().join("custom-claude");
        fs::create_dir_all(claude.join("projects/example")).unwrap();
        let configured: Config = toml::from_str(&format!(
            "version = 1\n[agents.claude]\nlocal_root = {:?}\n",
            claude.to_string_lossy()
        ))
        .unwrap();
        assert_eq!(
            configured
                .infer_agent(&claude.join("projects/example"))
                .unwrap(),
            AgentKind::Claude
        );
        assert!(configured.infer_agent(temp.path()).is_err());
    }
}