agent-offload 0.1.4

Launch coding agents in tmux panes and wait for completion
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

pub mod discovery;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub default_profile: String,
    #[serde(default)]
    pub headless: bool,
    pub profiles: BTreeMap<String, Profile>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Profile {
    pub command: String,
    #[serde(default)]
    pub interface: AgentInterface,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub env: BTreeMap<String, EnvValue>,
    #[serde(default)]
    pub prompt: PromptDelivery,
    #[serde(default)]
    pub headless: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum EnvValue {
    Literal(String),
    FromEnv(FromEnvValue),
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FromEnvValue {
    pub from_env: String,
}

#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentInterface {
    #[default]
    Generic,
    Claude,
    Codex,
    Cursor,
    Opencode,
}

#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PromptDelivery {
    Stdin,
    #[default]
    Argument,
    PromptFileArg,
}

pub fn default_config_path() -> Result<PathBuf> {
    let home = dirs::home_dir().context("could not find home directory")?;
    Ok(home
        .join(".config")
        .join("agent-offload")
        .join("config.yaml"))
}

pub fn load_config(path: Option<&Path>) -> Result<(Config, PathBuf)> {
    let path = match path {
        Some(path) => path.to_path_buf(),
        None => {
            let cwd = std::env::current_dir().context("could not read current directory")?;
            let home = dirs::home_dir();
            if let Some(path) = discovery::find_project_config(&cwd, home.as_deref())? {
                path
            } else {
                default_config_path()?
            }
        }
    };

    if !path.exists() {
        bail!("config file not found at {}", path.display());
    }

    let contents = fs::read_to_string(&path)
        .with_context(|| format!("could not read config file {}", path.display()))?;
    let config: Config = serde_yaml::from_str(&contents)
        .with_context(|| format!("could not parse config file {}", path.display()))?;

    config.validate()?;
    Ok((config, path))
}

impl Config {
    pub fn validate(&self) -> Result<()> {
        if self.profiles.is_empty() {
            bail!("config must define at least one profile");
        }

        if !self.profiles.contains_key(&self.default_profile) {
            bail!("default profile {:?} is not defined", self.default_profile);
        }

        for (name, profile) in &self.profiles {
            if profile.command.trim().is_empty() {
                bail!("profile {name:?} must define a command");
            }

            for key in profile.env.keys() {
                if !is_env_key(key) {
                    bail!("profile {name:?} has invalid env key {key:?}");
                }
            }

            for value in profile.env.values() {
                if let EnvValue::FromEnv(from_env) = value
                    && !is_env_key(&from_env.from_env)
                {
                    bail!(
                        "profile {name:?} has invalid from_env name {:?}",
                        from_env.from_env
                    );
                }
            }

            if matches!(profile.prompt, PromptDelivery::PromptFileArg)
                && !profile.args.iter().any(|arg| arg.contains("{prompt_file}"))
            {
                bail!("profile {name:?} uses prompt-file-arg but no arg contains {{prompt_file}}");
            }
        }

        Ok(())
    }

    pub fn resolve_profile<'a>(
        &'a self,
        requested: Option<&'a str>,
    ) -> Result<(&'a str, &'a Profile)> {
        let name = requested.unwrap_or(&self.default_profile);
        let profile = self
            .profiles
            .get(name)
            .with_context(|| format!("profile {name:?} is not defined"))?;

        Ok((name, profile))
    }
}

fn is_env_key(key: &str) -> bool {
    let mut chars = key.chars();
    matches!(chars.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
        && chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_profile_resolution() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            headless: true
            profiles:
              default:
                command: claude
                headless: true
            "#,
        )
        .unwrap();
        assert!(config.validate().is_ok());

        let (name, _) = config.resolve_profile(None).unwrap();
        assert_eq!(name, "default");
        assert!(config.headless);
        let (_, profile) = config.resolve_profile(None).unwrap();
        assert!(profile.headless);
    }

    #[test]
    fn test_headless_defaults_to_false() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: claude
            "#,
        )
        .unwrap();
        assert!(!config.headless);
        let (_, profile) = config.resolve_profile(None).unwrap();
        assert!(!profile.headless);
    }

    #[test]
    fn test_requested_profile_resolution() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: claude
              fast:
                command: claude
                args: ["--fast"]
            "#,
        )
        .unwrap();

        let (name, _) = config.resolve_profile(Some("fast")).unwrap();
        assert_eq!(name, "fast");
    }

    #[test]
    fn test_invalid_env_key() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: claude
                env:
                  "123invalid": "value"
            "#,
        )
        .unwrap();
        assert!(config.validate().is_err());
    }

    #[test]
    fn test_prompt_file_arg_requires_placeholder() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: agent
                prompt: prompt-file-arg
                args: ["--file", "/fixed/path"]
            "#,
        )
        .unwrap();
        assert!(config.validate().is_err());
    }

    #[test]
    fn test_prompt_file_arg_with_placeholder() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: agent
                prompt: prompt-file-arg
                args: ["--file", "{prompt_file}"]
            "#,
        )
        .unwrap();
        assert!(config.validate().is_ok());
    }

    #[test]
    fn test_missing_default_profile() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: missing
            profiles:
              default:
                command: claude
            "#,
        )
        .unwrap();
        assert!(config.validate().is_err());
    }

    #[test]
    fn test_empty_profiles() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles: {}
            "#,
        )
        .unwrap();
        assert!(config.validate().is_err());
    }

    #[test]
    fn test_from_env_value() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: claude
                env:
                  API_KEY:
                    from_env: MY_SECRET_KEY
            "#,
        )
        .unwrap();
        assert!(config.validate().is_ok());
    }

    #[test]
    fn test_invalid_from_env_name() {
        let config: Config = serde_yaml::from_str(
            r#"
            default_profile: default
            profiles:
              default:
                command: claude
                env:
                  API_KEY:
                    from_env: "123invalid"
            "#,
        )
        .unwrap();
        assert!(config.validate().is_err());
    }
}