Skip to main content

ao_core/
parity_config_validation.rs

1//! TS orchestrator-config validation rules
2//! (ported from `packages/core/src/config.ts`, validation section).
3//!
4//! Parity status: test-only.
5//!
6//! The production config loader in `crates/ao-core/src/config.rs` has its
7//! own (stricter) validator. This module exists only as a regression harness
8//! against the TS behavior. See `docs/ts-core-parity-report.md` →
9//! "Parity-only modules".
10
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TsProjectConfig {
16    pub repo: String,
17    pub path: String,
18    pub default_branch: String,
19    pub session_prefix: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TsOrchestratorConfig {
24    pub projects: HashMap<String, TsProjectConfig>,
25}
26
27pub fn generate_session_prefix(project_id: &str) -> String {
28    if project_id.len() <= 4 {
29        return project_id.to_lowercase();
30    }
31
32    let uppercase: Vec<char> = project_id
33        .chars()
34        .filter(|c| c.is_ascii_uppercase())
35        .collect();
36    if uppercase.len() > 1 {
37        return uppercase.into_iter().collect::<String>().to_lowercase();
38    }
39
40    if project_id.contains('-') || project_id.contains('_') {
41        let sep = if project_id.contains('-') { '-' } else { '_' };
42        return project_id
43            .split(sep)
44            .filter(|w| !w.is_empty())
45            .filter_map(|w| w.chars().next())
46            .collect::<String>()
47            .to_lowercase();
48    }
49
50    project_id
51        .chars()
52        .take(3)
53        .collect::<String>()
54        .to_lowercase()
55}
56
57pub fn validate_project_uniqueness(config: &TsOrchestratorConfig) -> Result<(), String> {
58    let mut basenames: HashSet<String> = HashSet::new();
59    let mut basename_to_paths: HashMap<String, Vec<String>> = HashMap::new();
60
61    for project in config.projects.values() {
62        let basename = Path::new(&project.path)
63            .file_name()
64            .and_then(|s| s.to_str())
65            .unwrap_or("")
66            .to_string();
67
68        basename_to_paths
69            .entry(basename.clone())
70            .or_default()
71            .push(project.path.clone());
72
73        if basenames.contains(&basename) {
74            let paths = basename_to_paths
75                .get(&basename)
76                .cloned()
77                .unwrap_or_default()
78                .join(", ");
79            return Err(format!(
80                "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."
81            ));
82        }
83        basenames.insert(basename);
84    }
85
86    let mut prefixes: HashSet<String> = HashSet::new();
87    let mut prefix_to_project_key: HashMap<String, String> = HashMap::new();
88
89    for (config_key, project) in config.projects.iter() {
90        let basename = Path::new(&project.path)
91            .file_name()
92            .and_then(|s| s.to_str())
93            .unwrap_or("")
94            .to_string();
95        let prefix = project
96            .session_prefix
97            .clone()
98            .unwrap_or_else(|| generate_session_prefix(&basename));
99
100        if prefixes.contains(&prefix) {
101            let first = prefix_to_project_key
102                .get(&prefix)
103                .cloned()
104                .unwrap_or_default();
105            let first_path = config
106                .projects
107                .get(&first)
108                .map(|p| p.path.clone())
109                .unwrap_or_default();
110            return Err(format!(
111                "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",
112                path = project.path
113            ));
114        }
115
116        prefixes.insert(prefix.clone());
117        prefix_to_project_key.insert(prefix, config_key.clone());
118    }
119
120    Ok(())
121}