ao-core 0.1.0

Core traits and types for the ao-rs agent orchestrator framework
Documentation
//! TS orchestrator-config validation rules
//! (ported from `packages/core/src/config.ts`, validation section).
//!
//! Parity status: test-only.
//!
//! The production config loader in `crates/ao-core/src/config.rs` has its
//! own (stricter) validator. This module exists only as a regression harness
//! against the TS behavior. See `docs/ts-core-parity-report.md` →
//! "Parity-only modules".

use std::collections::{HashMap, HashSet};
use std::path::Path;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TsProjectConfig {
    pub repo: String,
    pub path: String,
    pub default_branch: String,
    pub session_prefix: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TsOrchestratorConfig {
    pub projects: HashMap<String, TsProjectConfig>,
}

pub fn generate_session_prefix(project_id: &str) -> String {
    if project_id.len() <= 4 {
        return project_id.to_lowercase();
    }

    let uppercase: Vec<char> = project_id
        .chars()
        .filter(|c| c.is_ascii_uppercase())
        .collect();
    if uppercase.len() > 1 {
        return uppercase.into_iter().collect::<String>().to_lowercase();
    }

    if project_id.contains('-') || project_id.contains('_') {
        let sep = if project_id.contains('-') { '-' } else { '_' };
        return project_id
            .split(sep)
            .filter(|w| !w.is_empty())
            .filter_map(|w| w.chars().next())
            .collect::<String>()
            .to_lowercase();
    }

    project_id
        .chars()
        .take(3)
        .collect::<String>()
        .to_lowercase()
}

pub fn validate_project_uniqueness(config: &TsOrchestratorConfig) -> Result<(), String> {
    let mut basenames: HashSet<String> = HashSet::new();
    let mut basename_to_paths: HashMap<String, Vec<String>> = HashMap::new();

    for project in config.projects.values() {
        let basename = Path::new(&project.path)
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("")
            .to_string();

        basename_to_paths
            .entry(basename.clone())
            .or_default()
            .push(project.path.clone());

        if basenames.contains(&basename) {
            let paths = basename_to_paths
                .get(&basename)
                .cloned()
                .unwrap_or_default()
                .join(", ");
            return Err(format!(
                "Duplicate project ID detected: \"{basename}\"\nMultiple projects have the same directory basename:\n  {paths}\n\nTo fix this, ensure each project path has a unique directory name.\nAlternatively, you can use the config key as a unique identifier."
            ));
        }
        basenames.insert(basename);
    }

    let mut prefixes: HashSet<String> = HashSet::new();
    let mut prefix_to_project_key: HashMap<String, String> = HashMap::new();

    for (config_key, project) in config.projects.iter() {
        let basename = Path::new(&project.path)
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("")
            .to_string();
        let prefix = project
            .session_prefix
            .clone()
            .unwrap_or_else(|| generate_session_prefix(&basename));

        if prefixes.contains(&prefix) {
            let first = prefix_to_project_key
                .get(&prefix)
                .cloned()
                .unwrap_or_default();
            let first_path = config
                .projects
                .get(&first)
                .map(|p| p.path.clone())
                .unwrap_or_default();
            return Err(format!(
                "Duplicate session prefix detected: \"{prefix}\"\nProjects \"{first}\" and \"{config_key}\" would generate the same prefix.\n\nTo fix this, add an explicit sessionPrefix to one of these projects:\n\nprojects:\n  {first}:\n    path: {first_path}\n    sessionPrefix: {prefix}1  # Add explicit prefix\n  {config_key}:\n    path: {path}\n    sessionPrefix: {prefix}2  # Add explicit prefix\n",
                path = project.path
            ));
        }

        prefixes.insert(prefix.clone());
        prefix_to_project_key.insert(prefix, config_key.clone());
    }

    Ok(())
}