macot 0.1.11

Multi Agent Control Tower - CLI for orchestrating Claude CLI instances
Documentation
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeSession {
    pub session_id: Option<String>,
    pub last_conversation_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAnalysis {
    pub path: String,
    pub summary: String,
    pub last_read: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pattern {
    pub pattern_type: String,
    pub pattern: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
    pub from: String,
    pub to: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Knowledge {
    #[serde(default)]
    pub files_analyzed: Vec<FileAnalysis>,
    #[serde(default)]
    pub patterns_discovered: Vec<Pattern>,
    #[serde(default)]
    pub dependencies_mapped: Vec<Dependency>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpertContext {
    pub expert_id: u32,
    pub expert_name: String,
    pub session_hash: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    #[serde(default)]
    pub claude_session: ClaudeSession,
    #[serde(default)]
    pub knowledge: Knowledge,
    #[serde(default)]
    pub worktree_branch: Option<String>,
    #[serde(default)]
    pub worktree_path: Option<String>,
}

impl ExpertContext {
    pub fn new(expert_id: u32, expert_name: String, session_hash: String) -> Self {
        let now = Utc::now();
        Self {
            expert_id,
            expert_name,
            session_hash,
            created_at: now,
            updated_at: now,
            claude_session: ClaudeSession::default(),
            knowledge: Knowledge::default(),
            worktree_branch: None,
            worktree_path: None,
        }
    }

    pub fn touch(&mut self) {
        self.updated_at = Utc::now();
    }

    #[allow(dead_code)]
    pub fn set_session_id(&mut self, session_id: String) {
        self.claude_session.session_id = Some(session_id);
        self.touch();
    }

    #[allow(dead_code)]
    pub fn add_file_analysis(&mut self, path: String, summary: String) {
        self.knowledge.files_analyzed.push(FileAnalysis {
            path,
            summary,
            last_read: Utc::now(),
        });
        self.touch();
    }

    #[allow(dead_code)]
    pub fn clear_knowledge(&mut self) {
        self.knowledge = Knowledge::default();
        self.touch();
    }

    pub fn clear_session(&mut self) {
        self.claude_session = ClaudeSession::default();
        self.touch();
    }

    pub fn set_worktree(&mut self, branch: String, path: String) {
        self.worktree_branch = Some(branch);
        self.worktree_path = Some(path);
        self.touch();
    }

    pub fn clear_worktree(&mut self) {
        self.worktree_branch = None;
        self.worktree_path = None;
        self.touch();
    }
}

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

    #[test]
    fn expert_context_new_creates_with_defaults() {
        let ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());

        assert_eq!(ctx.expert_id, 0);
        assert_eq!(ctx.expert_name, "architect");
        assert_eq!(ctx.session_hash, "abc123");
        assert!(ctx.claude_session.session_id.is_none());
        assert!(ctx.knowledge.files_analyzed.is_empty());
    }

    #[test]
    fn expert_context_set_session_id_updates_context() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        let initial_updated = ctx.updated_at;

        std::thread::sleep(std::time::Duration::from_millis(10));
        ctx.set_session_id("session-xyz".to_string());

        assert_eq!(
            ctx.claude_session.session_id,
            Some("session-xyz".to_string())
        );
        assert!(ctx.updated_at > initial_updated);
    }

    #[test]
    fn expert_context_add_file_analysis_appends() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());

        ctx.add_file_analysis("src/main.rs".to_string(), "Main entry point".to_string());
        ctx.add_file_analysis("src/lib.rs".to_string(), "Library root".to_string());

        assert_eq!(ctx.knowledge.files_analyzed.len(), 2);
        assert_eq!(ctx.knowledge.files_analyzed[0].path, "src/main.rs");
        assert_eq!(ctx.knowledge.files_analyzed[1].path, "src/lib.rs");
    }

    #[test]
    fn expert_context_clear_knowledge_resets() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        ctx.add_file_analysis("src/main.rs".to_string(), "Test".to_string());

        ctx.clear_knowledge();

        assert!(ctx.knowledge.files_analyzed.is_empty());
    }

    #[test]
    fn expert_context_serializes_to_yaml() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        ctx.set_session_id("session-xyz".to_string());

        let yaml = serde_yaml::to_string(&ctx).unwrap();
        assert!(yaml.contains("expert_id: 0"));
        assert!(yaml.contains("expert_name: architect"));
        assert!(yaml.contains("session_id: session-xyz"));
    }

    #[test]
    fn expert_context_set_worktree_stores_branch_and_path() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());

        ctx.set_worktree(
            "add-auth-20260207-120000".to_string(),
            "/tmp/worktrees/add-auth-20260207-120000".to_string(),
        );

        assert_eq!(
            ctx.worktree_branch,
            Some("add-auth-20260207-120000".to_string()),
            "set_worktree: should store branch name"
        );
        assert_eq!(
            ctx.worktree_path,
            Some("/tmp/worktrees/add-auth-20260207-120000".to_string()),
            "set_worktree: should store worktree path"
        );
    }

    #[test]
    fn expert_context_set_worktree_calls_touch() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        let initial_updated = ctx.updated_at;

        std::thread::sleep(std::time::Duration::from_millis(10));
        ctx.set_worktree("branch".to_string(), "/tmp/wt".to_string());

        assert!(
            ctx.updated_at > initial_updated,
            "set_worktree: should update updated_at via touch()"
        );
    }

    #[test]
    fn expert_context_serializes_worktree_fields_to_yaml() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        ctx.set_worktree(
            "feature-branch".to_string(),
            "/tmp/wt/feature-branch".to_string(),
        );

        let yaml = serde_yaml::to_string(&ctx).unwrap();
        assert!(
            yaml.contains("worktree_branch: feature-branch"),
            "serializes_worktree: YAML should contain worktree_branch"
        );
        assert!(
            yaml.contains("worktree_path: /tmp/wt/feature-branch"),
            "serializes_worktree: YAML should contain worktree_path"
        );
    }

    #[test]
    fn expert_context_deserializes_worktree_fields_from_yaml() {
        let yaml = r#"
expert_id: 2
expert_name: backend
session_hash: xyz789
created_at: "2024-01-15T10:00:00Z"
updated_at: "2024-01-15T11:00:00Z"
worktree_branch: feature-auth
worktree_path: /tmp/wt/feature-auth
"#;

        let ctx: ExpertContext = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(
            ctx.worktree_branch,
            Some("feature-auth".to_string()),
            "deserializes_worktree: should parse worktree_branch"
        );
        assert_eq!(
            ctx.worktree_path,
            Some("/tmp/wt/feature-auth".to_string()),
            "deserializes_worktree: should parse worktree_path"
        );
    }

    #[test]
    fn expert_context_backward_compatible_deserialization_without_worktree() {
        let yaml = r#"
expert_id: 1
expert_name: frontend
session_hash: def456
created_at: "2024-01-15T10:00:00Z"
updated_at: "2024-01-15T11:00:00Z"
"#;

        let ctx: ExpertContext = serde_yaml::from_str(yaml).unwrap();
        assert!(
            ctx.worktree_branch.is_none(),
            "backward_compat: worktree_branch should default to None"
        );
        assert!(
            ctx.worktree_path.is_none(),
            "backward_compat: worktree_path should default to None"
        );
    }

    #[test]
    fn expert_context_new_has_no_worktree() {
        let ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        assert!(
            ctx.worktree_branch.is_none(),
            "new: worktree_branch should be None by default"
        );
        assert!(
            ctx.worktree_path.is_none(),
            "new: worktree_path should be None by default"
        );
    }

    #[test]
    fn expert_context_clear_worktree_resets_fields_to_none() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        ctx.set_worktree(
            "feature-branch".to_string(),
            "/tmp/wt/feature-branch".to_string(),
        );

        ctx.clear_worktree();

        assert!(
            ctx.worktree_branch.is_none(),
            "clear_worktree: worktree_branch should be None after clear"
        );
        assert!(
            ctx.worktree_path.is_none(),
            "clear_worktree: worktree_path should be None after clear"
        );
    }

    #[test]
    fn expert_context_clear_worktree_calls_touch() {
        let mut ctx = ExpertContext::new(0, "architect".to_string(), "abc123".to_string());
        ctx.set_worktree("branch".to_string(), "/tmp/wt".to_string());
        let initial_updated = ctx.updated_at;

        std::thread::sleep(std::time::Duration::from_millis(10));
        ctx.clear_worktree();

        assert!(
            ctx.updated_at > initial_updated,
            "clear_worktree: should update updated_at via touch()"
        );
    }

    #[test]
    fn expert_context_deserializes_from_yaml() {
        let yaml = r#"
expert_id: 1
expert_name: frontend
session_hash: def456
created_at: "2024-01-15T10:00:00Z"
updated_at: "2024-01-15T11:00:00Z"
claude_session:
  session_id: session-abc
knowledge:
  files_analyzed:
    - path: "src/App.tsx"
      summary: "Main React component"
      last_read: "2024-01-15T10:30:00Z"
task_history:
  - task_id: task-001
    status: done
    summary: Created login form
"#;

        let ctx: ExpertContext = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(ctx.expert_id, 1);
        assert_eq!(ctx.expert_name, "frontend");
        assert_eq!(
            ctx.claude_session.session_id,
            Some("session-abc".to_string())
        );
        assert_eq!(ctx.knowledge.files_analyzed.len(), 1);
    }
}