ito_core/validate/
repo_integrity.rs

1//! Cross-artifact repository integrity checks.
2//!
3//! These checks validate relationships between on-disk Ito artifacts that are
4//! hard to express as a single file-local validation.
5
6use crate::error_bridge::IntoCoreResult;
7use crate::errors::{CoreError, CoreResult};
8use crate::validate::{ValidationIssue, error};
9use ito_common::fs::StdFs;
10use ito_common::id;
11use ito_domain::discovery;
12use rusqlite::Connection;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16/// Convert a `rusqlite::Result` into a `CoreResult`.
17fn sqlite<T>(r: rusqlite::Result<T>) -> CoreResult<T> {
18    r.map_err(|e| CoreError::Sqlite(format!("sqlite error: {e}")))
19}
20
21/// Extract the 3-digit module id prefix from a module directory name.
22///
23/// Module directories are expected to be in the format `NNN_slug`.
24fn parse_module_id_from_dir_name(dir_name: &str) -> Option<String> {
25    let (id_part, _slug) = dir_name.split_once('_')?;
26    if id_part.len() != 3 || !id_part.chars().all(|c| c.is_ascii_digit()) {
27        return None;
28    }
29    Some(id_part.to_string())
30}
31
32/// Validate change directory naming and cross-references.
33///
34/// The returned map is keyed by change directory name. A directory is present in
35/// the map only if at least one integrity issue was found.
36pub fn validate_change_dirs_repo_integrity(
37    ito_path: &Path,
38) -> CoreResult<BTreeMap<String, Vec<ValidationIssue>>> {
39    let mut by_dir: BTreeMap<String, Vec<ValidationIssue>> = BTreeMap::new();
40
41    let mut module_ids: BTreeSet<String> = BTreeSet::new();
42    for m in discovery::list_module_dir_names(&StdFs, ito_path).into_core()? {
43        if let Some(id) = parse_module_id_from_dir_name(&m) {
44            module_ids.insert(id);
45        }
46    }
47
48    let change_dirs = discovery::list_change_dir_names(&StdFs, ito_path).into_core()?;
49    if change_dirs.is_empty() {
50        return Ok(by_dir);
51    }
52
53    let conn = sqlite(Connection::open_in_memory())?;
54    sqlite(conn.execute_batch(
55        r#"
56CREATE TABLE change_dir (
57  dir_name TEXT NOT NULL,
58  numeric_id TEXT NOT NULL,
59  module_id TEXT NOT NULL,
60  change_num TEXT NOT NULL,
61  slug TEXT NOT NULL
62);
63"#,
64    ))?;
65
66    for dir_name in &change_dirs {
67        match id::parse_change_id(dir_name) {
68            Ok(p) => {
69                let numeric_id = format!("{}-{}", p.module_id, p.change_num);
70                let slug = p.name;
71                sqlite(conn.execute(
72                    "INSERT INTO change_dir (dir_name, numeric_id, module_id, change_num, slug) VALUES (?1, ?2, ?3, ?4, ?5)",
73                    rusqlite::params![dir_name, numeric_id, p.module_id.as_str(), p.change_num, slug],
74                ))?;
75            }
76            Err(e) => {
77                let msg = if let Some(hint) = e.hint.as_deref() {
78                    format!(
79                        "Invalid change directory name '{dir_name}': {} (hint: {hint})",
80                        e.error
81                    )
82                } else {
83                    format!("Invalid change directory name '{dir_name}': {}", e.error)
84                };
85                by_dir
86                    .entry(dir_name.clone())
87                    .or_default()
88                    .push(error("id", msg));
89            }
90        }
91    }
92
93    // Module existence for parsed change dirs.
94    {
95        let mut stmt = sqlite(conn.prepare("SELECT dir_name, module_id FROM change_dir"))?;
96        let mut rows = sqlite(stmt.query([]))?;
97        while let Some(row) = sqlite(rows.next())? {
98            let dir_name: String = sqlite(row.get(0))?;
99            let module_id: String = sqlite(row.get(1))?;
100            if !module_ids.contains(&module_id) {
101                by_dir.entry(dir_name.clone()).or_default().push(error(
102                    "module",
103                    format!("Change '{dir_name}' refers to missing module '{module_id}'"),
104                ));
105            }
106        }
107    }
108
109    // Duplicate numeric change IDs.
110    {
111        let mut stmt = sqlite(conn.prepare(
112            "SELECT numeric_id FROM change_dir GROUP BY numeric_id HAVING COUNT(*) > 1 ORDER BY numeric_id",
113        ))?;
114        let mut rows = sqlite(stmt.query([]))?;
115        while let Some(row) = sqlite(rows.next())? {
116            let numeric_id: String = sqlite(row.get(0))?;
117            let mut stmt2 = sqlite(conn.prepare(
118                "SELECT dir_name FROM change_dir WHERE numeric_id = ?1 ORDER BY dir_name",
119            ))?;
120            let dirs: Vec<String> = stmt2
121                .query_map([numeric_id.as_str()], |r| r.get(0))
122                .map_err(|e| CoreError::Sqlite(format!("sqlite error: {e}")))?
123                .collect::<std::result::Result<Vec<_>, _>>()
124                .map_err(|e| CoreError::Sqlite(format!("sqlite error: {e}")))?;
125            for d in &dirs {
126                let others: Vec<&str> = dirs
127                    .iter()
128                    .filter(|x| *x != d)
129                    .map(|s| s.as_str())
130                    .collect();
131                by_dir.entry(d.clone()).or_default().push(error(
132                    "id",
133                    format!(
134                        "Duplicate numeric change id {numeric_id}: also found at {}",
135                        others.join(", ")
136                    ),
137                ));
138            }
139        }
140    }
141
142    Ok(by_dir)
143}