mars-agents 0.11.0

Agent package manager for .agents/ directories
Documentation
//! Native hook discovery, validation, and deterministic ordering.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::Deserialize;

use crate::error::{ConfigError, MarsError};

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind")]
pub enum HookAction {
    #[serde(rename = "script")]
    Script { path: String },
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookTarget {
    pub events: Vec<String>,
    #[serde(default)]
    pub matcher: Option<String>,
    #[serde(default)]
    pub unchecked: bool,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawHookDef {
    name: String,
    #[serde(default = "default_visibility")]
    visibility: String,
    targets: BTreeMap<String, HookTarget>,
    action: HookAction,
    #[serde(default)]
    order: i32,
}

fn default_visibility() -> String {
    "local".to_string()
}

#[derive(Debug, Clone)]
pub struct HookDef {
    pub name: String,
    pub visibility: String,
    pub targets: BTreeMap<String, HookTarget>,
    pub action: HookAction,
    pub order: i32,
}

#[derive(Debug, Clone)]
pub struct ParsedHookItem {
    pub def: HookDef,
    pub source_name: String,
    pub package_depth: usize,
    pub decl_order: usize,
    pub package_root: PathBuf,
}

fn validate_path_component(name: &str) -> Result<(), &'static str> {
    if name.contains('\0') {
        return Err("contains null byte");
    }
    for component in Path::new(name).components() {
        use std::path::Component;
        match component {
            Component::ParentDir => return Err("contains `..` component"),
            Component::RootDir | Component::Prefix(_) => {
                return Err("must not be an absolute path");
            }
            _ => {}
        }
    }
    Ok(())
}

fn validate_hook_script_path(path: &str) -> Result<(), &'static str> {
    if path.contains('\0') {
        return Err("contains null byte");
    }
    use std::path::Component;
    for component in Path::new(path).components() {
        match component {
            Component::ParentDir => return Err("contains `..` component"),
            Component::RootDir | Component::Prefix(_) => {
                return Err("must not be an absolute path");
            }
            _ => {}
        }
    }
    Ok(())
}

pub fn discover_hook_items(
    package_root: &Path,
    source_name: &str,
    package_depth: usize,
    decl_order: usize,
) -> Result<Vec<ParsedHookItem>, MarsError> {
    let hooks_dir = package_root.join("hooks");
    if !hooks_dir.is_dir() {
        return Ok(Vec::new());
    }

    let mut items = Vec::new();
    let mut entries: Vec<_> = std::fs::read_dir(&hooks_dir)
        .map_err(MarsError::from)?
        .filter_map(Result::ok)
        .filter(|entry| entry.path().is_dir())
        .collect();
    entries.sort_by_key(|entry| entry.file_name());

    for entry in entries {
        if entry.file_name().to_string_lossy().starts_with('.') {
            continue;
        }
        let toml_path = entry.path().join("hook.toml");
        if !toml_path.is_file() {
            continue;
        }
        let raw = std::fs::read_to_string(&toml_path).map_err(MarsError::from)?;
        let value: toml::Value =
            toml::from_str(&raw).map_err(|error| invalid_parse(&toml_path, error))?;
        if value.get("event").is_some() || value.get("targets").is_some_and(toml::Value::is_array) {
            return Err(MarsError::Config(ConfigError::Invalid {
                message: format!(
                    "{} uses the removed universal hook schema; migrate by replacing `event =` \
                     and `targets = [...]` with `[targets.\".claude\"]` (or another target) and \
                     `events = [\"<native event>\"]`",
                    toml_path.display()
                ),
            }));
        }
        let raw_def: RawHookDef =
            toml::from_str(&raw).map_err(|error| invalid_parse(&toml_path, error))?;

        if raw_def.targets.is_empty() {
            return Err(invalid(&toml_path, "at least one target table is required"));
        }
        for (target, spec) in &raw_def.targets {
            if spec.events.is_empty() {
                return Err(invalid(
                    &toml_path,
                    &format!("target `{target}` must declare at least one event"),
                ));
            }
            if spec.events.iter().any(|event| event.is_empty()) {
                return Err(invalid(&toml_path, "hook event names must not be empty"));
            }
        }
        if let Err(message) = validate_path_component(&raw_def.name) {
            return Err(invalid(
                &toml_path,
                &format!("invalid name `{}`: {message}", raw_def.name),
            ));
        }
        let HookAction::Script { path } = &raw_def.action;
        if let Err(message) = validate_hook_script_path(path) {
            return Err(invalid(
                &toml_path,
                &format!(
                    "hook `{}` has invalid script path `{path}`: {message}",
                    raw_def.name
                ),
            ));
        }

        items.push(ParsedHookItem {
            def: HookDef {
                name: raw_def.name,
                visibility: raw_def.visibility,
                targets: raw_def.targets,
                action: raw_def.action,
                order: raw_def.order,
            },
            source_name: source_name.to_string(),
            package_depth,
            decl_order,
            package_root: package_root.to_path_buf(),
        });
    }
    Ok(items)
}

fn invalid_parse(path: &Path, error: toml::de::Error) -> MarsError {
    invalid(path, &format!("failed to parse: {error}"))
}

fn invalid(path: &Path, message: &str) -> MarsError {
    MarsError::Config(ConfigError::Invalid {
        message: format!("{}: {message}", path.display()),
    })
}

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

    fn write_hook(root: &Path, name: &str, body: &str) {
        let dir = root.join("hooks").join(name);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("hook.toml"), body).unwrap();
    }

    #[test]
    fn parses_multi_target_multi_event_and_matchers() {
        let temp = TempDir::new().unwrap();
        write_hook(
            temp.path(),
            "audit",
            r#"
name = "audit"
[targets.".claude"]
events = ["PreToolUse", "PostToolUse"]
matcher = "Bash"
[targets.".codex"]
events = ["SessionStart"]
[action]
kind = "script"
path = "run.sh"
"#,
        );
        let items = discover_hook_items(temp.path(), "base", 0, 0).unwrap();
        assert_eq!(items[0].def.targets[".claude"].events.len(), 2);
        assert_eq!(
            items[0].def.targets[".claude"].matcher.as_deref(),
            Some("Bash")
        );
        assert!(items[0].def.targets[".codex"].matcher.is_none());
    }

    #[test]
    fn old_schema_error_names_file_and_gives_migration_hint() {
        let temp = TempDir::new().unwrap();
        write_hook(
            temp.path(),
            "old",
            r#"name = "old"
event = "tool.pre"
targets = [".claude"]
[action]
kind = "script"
path = "run.sh"
"#,
        );
        let error = discover_hook_items(temp.path(), "base", 0, 0)
            .unwrap_err()
            .to_string();
        assert!(error.contains("hook.toml"));
        assert!(error.contains("removed universal hook schema"));
        assert!(error.contains("[targets.\".claude\"]"));
    }

    #[test]
    fn rejects_empty_events() {
        let temp = TempDir::new().unwrap();
        write_hook(
            temp.path(),
            "bad",
            r#"name = "bad"
[targets.".claude"]
events = []
[action]
kind = "script"
path = "run.sh"
"#,
        );
        assert!(
            discover_hook_items(temp.path(), "base", 0, 0)
                .unwrap_err()
                .to_string()
                .contains("at least one event")
        );
    }
}