ito_core/validate/
repo_integrity.rs1use 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
16fn sqlite<T>(r: rusqlite::Result<T>) -> CoreResult<T> {
18 r.map_err(|e| CoreError::Sqlite(format!("sqlite error: {e}")))
19}
20
21fn 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
32pub 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 {
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 {
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}