workspace-mgr 0.1.0

Fixed-policy repository workspace manager for coding agents
use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::{Error, IoContext, Result};
use crate::git::GitRepo;
use crate::path::reject_symlink_traversal;

pub const CONFIG_NAME: &str = ".workspace-mgr.toml";

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub git: GitConfig,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub s3: Option<S3Config>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitConfig {
    pub remote: String,
    pub branch: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct S3Config {
    pub url: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub endpoint_url: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum StorageTarget {
    Git,
    S3,
}

impl Default for GitConfig {
    fn default() -> Self {
        Self {
            remote: "origin".to_owned(),
            branch: "main".to_owned(),
        }
    }
}

impl Config {
    pub fn path(repo: &GitRepo) -> PathBuf {
        repo.root.join(CONFIG_NAME)
    }

    pub fn load(repo: &GitRepo) -> Result<Self> {
        reject_symlink_traversal(&repo.root, CONFIG_NAME, "repository configuration")?;
        Self::load_path(&Self::path(repo))
    }

    pub fn load_compatible(repo: &GitRepo) -> Result<Self> {
        let config = Self::load(repo)?;
        repo.validate_remote_name(&config.git.remote)?;
        repo.validate_branch(&config.git.branch)?;
        repo.validate_branch(&format!(
            "{}workspace-mgr-probe",
            crate::policy::TASK_BRANCH_PREFIX
        ))?;
        Ok(config)
    }

    pub fn load_path(path: &Path) -> Result<Self> {
        let raw = fs::read_to_string(path).at(path)?;
        Self::parse(&raw, path)
    }

    pub fn parse(raw: &str, path: &Path) -> Result<Self> {
        let config: Self = toml::from_str(raw).map_err(|source| Error::Toml {
            path: path.to_path_buf(),
            source,
        })?;
        config.validate()?;
        Ok(config)
    }

    pub fn render(&self) -> Result<String> {
        toml::to_string_pretty(self)
            .map_err(|error| Error::message(format!("failed to render config: {error}")))
    }

    pub fn validate(&self) -> Result<()> {
        for (field, value) in [
            ("git.remote", &self.git.remote),
            ("git.branch", &self.git.branch),
        ] {
            if value.trim().is_empty() || value.contains('\n') {
                return Err(Error::message(format!(
                    "{field} must be a non-empty single-line string"
                )));
            }
        }
        validate_remote_name(&self.git.remote)?;
        if let Some(s3) = &self.s3 {
            validate_s3_url("s3.url", &s3.url)?;
            if let Some(endpoint) = &s3.endpoint_url {
                validate_endpoint_url("s3.endpoint_url", endpoint)?;
            }
        }
        Ok(())
    }

    pub fn s3_enabled(&self) -> bool {
        self.s3.is_some()
    }

    pub fn requires_object_versioning(&self) -> bool {
        self.s3
            .as_ref()
            .is_some_and(|s3| s3.url.starts_with("s3://"))
    }
}

fn validate_remote_name(value: &str) -> Result<()> {
    if value.starts_with('-')
        || value.chars().any(char::is_whitespace)
        || value.chars().any(char::is_control)
    {
        return Err(Error::message(
            "git.remote must be a safe Git remote name, not an option or URL",
        ));
    }
    Ok(())
}

fn validate_s3_url(field: &str, value: &str) -> Result<()> {
    validate_public_location(field, value)?;
    if value.starts_with("s3://") {
        let authority = value
            .trim_start_matches("s3://")
            .split('/')
            .next()
            .unwrap_or_default();
        if authority.is_empty() {
            return Err(Error::message(format!(
                "{field} must name a non-empty S3 bucket"
            )));
        }
        return Ok(());
    }
    #[cfg(feature = "test-storage")]
    {
        if !value.contains("://") {
            return Ok(());
        }
    }
    Err(Error::message(format!(
        "{field} must use s3://; filesystem storage is available only in test builds"
    )))
}

fn validate_endpoint_url(field: &str, value: &str) -> Result<()> {
    validate_public_location(field, value)?;
    if !value.starts_with("https://") && !value.starts_with("http://") {
        return Err(Error::message(format!(
            "{field} must use https:// or http://"
        )));
    }
    let authority = value
        .split_once("://")
        .map(|(_, remainder)| remainder.split('/').next().unwrap_or_default())
        .unwrap_or_default();
    if authority.is_empty() {
        return Err(Error::message(format!(
            "{field} must name a non-empty endpoint host"
        )));
    }
    Ok(())
}

fn validate_public_location(field: &str, value: &str) -> Result<()> {
    if value.trim().is_empty()
        || value != value.trim()
        || value.chars().any(char::is_control)
        || value.chars().any(char::is_whitespace)
    {
        return Err(Error::message(format!(
            "{field} must be a non-empty value without whitespace"
        )));
    }
    if value.contains(['?', '#']) {
        return Err(Error::message(format!(
            "{field} must not contain a query or fragment because tracked locations cannot contain credentials"
        )));
    }
    if let Some((_, authority_and_path)) = value.split_once("://") {
        let authority = authority_and_path.split('/').next().unwrap_or_default();
        if authority.contains('@') {
            return Err(Error::message(format!(
                "{field} must not contain embedded credentials; use environment credentials or ignored local configuration"
            )));
        }
    }
    Ok(())
}

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

    #[test]
    fn rejects_option_like_remotes_and_credential_bearing_locations() {
        let mut config = Config::default();
        config.git.remote = "--upload-pack=printf injected".to_owned();
        assert!(config.validate().is_err());

        config.git.remote = "origin".to_owned();
        config.s3 = Some(S3Config {
            url: "s3://bucket/prefix?X-Amz-Signature=secret".to_owned(),
            endpoint_url: None,
        });
        assert!(config.validate().is_err());

        config.s3 = Some(S3Config {
            url: "s3://bucket/prefix".to_owned(),
            endpoint_url: Some("https://user:secret@example.invalid".to_owned()),
        });
        assert!(config.validate().is_err());
    }

    #[test]
    fn tracked_git_facts_are_required() {
        for raw in ["[git]\nbranch = \"main\"\n", "[git]\nremote = \"origin\"\n"] {
            assert!(
                toml::from_str::<Config>(raw).is_err(),
                "incomplete configuration was accepted: {raw:?}"
            );
        }
    }

    #[cfg(not(feature = "test-storage"))]
    #[test]
    fn production_build_rejects_filesystem_storage() {
        let mut config = Config::default();
        config.s3 = Some(S3Config {
            url: "/tmp/test-storage".to_owned(),
            endpoint_url: None,
        });
        assert!(config.validate().is_err());
    }
}