1use std::{collections::HashSet, path::Path};
2
3use serde::{Deserialize, Serialize};
4
5use super::{DomainError, error::required_text};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum RepositoryMapType {
11 Knowledge,
12 Codespec,
13}
14
15impl RepositoryMapType {
16 pub fn as_str(self) -> &'static str {
17 match self {
18 Self::Knowledge => "knowledge",
19 Self::Codespec => "codespec",
20 }
21 }
22
23 pub fn required_directories(self) -> &'static [&'static str] {
24 match self {
25 Self::Knowledge => &["domain", "guides", "ops", "glossary", "best-practices"],
26 Self::Codespec => &["requirements", "design", "api", "test", "decisions"],
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum DirectoryLoadHint {
35 Always,
36 TaskMatch,
37 OnDemand,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum DirectoryUpdateRule {
44 Reviewed,
45 Generated,
46 ExternalSync,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum DirectoryRelationKind {
53 DependsOn,
54 Implements,
55 Documents,
56 Tests,
57 Operates,
58 RelatedTo,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct DirectoryRelation {
64 pub kind: DirectoryRelationKind,
65 pub target: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RepositoryMapDirectory {
71 pub directory: String,
72 pub purpose: String,
73 #[serde(default)]
74 pub content_scope: Vec<String>,
75 #[serde(default)]
76 pub key_files: Vec<String>,
77 pub load_hint: DirectoryLoadHint,
78 #[serde(default)]
79 pub relations: Vec<DirectoryRelation>,
80 pub update_rule: DirectoryUpdateRule,
81}
82
83impl RepositoryMapDirectory {
84 pub fn validate(&self, map_type: RepositoryMapType) -> Result<(), DomainError> {
85 validate_relative_path("directory", &self.directory)?;
86 required_text("purpose", self.purpose.as_str())?;
87 if self.content_scope.is_empty() {
88 return Err(DomainError::invalid("content_scope", "must not be empty"));
89 }
90 unique_values("content_scope", &self.content_scope)?;
91 unique_values("key_files", &self.key_files)?;
92 for pattern in &self.content_scope {
93 validate_glob(pattern)?;
94 if !is_owned_path(map_type, &self.directory, pattern) {
95 return Err(DomainError::invalid(
96 "content_scope",
97 "patterns must stay inside the governed directory",
98 ));
99 }
100 }
101 for path in &self.key_files {
102 validate_relative_path("key_files", path)?;
103 if !is_owned_path(map_type, &self.directory, path) {
104 return Err(DomainError::invalid(
105 "key_files",
106 "paths must stay inside the governed directory",
107 ));
108 }
109 }
110 let mut relations = HashSet::new();
111 for relation in &self.relations {
112 validate_relation_target(&relation.target)?;
113 if !relations.insert((relation.kind, relation.target.to_lowercase())) {
114 return Err(DomainError::invalid("relations", "entries must be unique"));
115 }
116 let own_target = format!("{}:{}", map_type.as_str(), self.directory);
117 if relation.target.eq_ignore_ascii_case(&own_target) {
118 return Err(DomainError::invalid(
119 "relations",
120 "self references are not allowed",
121 ));
122 }
123 }
124 Ok(())
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct RepositoryMapDirectoryChange {
131 pub directory: String,
132 pub purpose: Option<String>,
133 pub content_scope: Option<Vec<String>>,
134 pub key_files: Option<Vec<String>>,
135 pub load_hint: Option<DirectoryLoadHint>,
136 pub relations: Option<Vec<DirectoryRelation>>,
137 pub update_rule: Option<DirectoryUpdateRule>,
138}
139
140pub(crate) fn validate_directory_collection(
141 map_type: RepositoryMapType,
142 directories: &[RepositoryMapDirectory],
143 require_baseline: bool,
144) -> Result<(), DomainError> {
145 let mut names = HashSet::new();
146 for directory in directories {
147 directory.validate(map_type)?;
148 if !names.insert(directory.directory.to_lowercase()) {
149 return Err(DomainError::invalid(
150 "directories",
151 "directory paths must be unique without case collisions",
152 ));
153 }
154 }
155 if require_baseline {
156 for required in map_type.required_directories() {
157 if !names.contains(*required) {
158 return Err(DomainError::invalid(
159 "directories",
160 format!("required directory '{required}' is missing"),
161 ));
162 }
163 }
164 }
165 validate_relation_graph(map_type, directories)
166}
167
168fn validate_relation_graph(
169 map_type: RepositoryMapType,
170 directories: &[RepositoryMapDirectory],
171) -> Result<(), DomainError> {
172 let local = directories
173 .iter()
174 .map(|entry| entry.directory.as_str())
175 .collect::<HashSet<_>>();
176 for entry in directories {
177 for relation in &entry.relations {
178 let Some((target_type, target_directory)) = relation.target.split_once(':') else {
179 continue;
180 };
181 if target_type == map_type.as_str() && !local.contains(target_directory) {
182 return Err(DomainError::invalid(
183 "relations",
184 format!("target '{}' does not exist", relation.target),
185 ));
186 }
187 }
188 }
189 for entry in directories {
190 let mut visiting = HashSet::new();
191 if depends_on_cycle(
192 map_type,
193 entry.directory.as_str(),
194 directories,
195 &mut visiting,
196 ) {
197 return Err(DomainError::invalid(
198 "relations",
199 "depends_on relationships must be acyclic",
200 ));
201 }
202 }
203 Ok(())
204}
205
206fn depends_on_cycle<'a>(
207 map_type: RepositoryMapType,
208 directory: &'a str,
209 directories: &'a [RepositoryMapDirectory],
210 visiting: &mut HashSet<&'a str>,
211) -> bool {
212 if !visiting.insert(directory) {
213 return true;
214 }
215 let prefix = format!("{}:", map_type.as_str());
216 if let Some(entry) = directories
217 .iter()
218 .find(|entry| entry.directory == directory)
219 {
220 for target in entry
221 .relations
222 .iter()
223 .filter(|relation| relation.kind == DirectoryRelationKind::DependsOn)
224 .filter_map(|relation| relation.target.strip_prefix(&prefix))
225 {
226 if depends_on_cycle(map_type, target, directories, visiting) {
227 return true;
228 }
229 }
230 }
231 visiting.remove(directory);
232 false
233}
234
235fn validate_relative_path(field: &'static str, value: &str) -> Result<(), DomainError> {
236 let text = required_text(field, value)?;
237 let path = Path::new(text.as_str());
238 if path.is_absolute()
239 || value.contains('\\')
240 || path.components().any(|component| {
241 matches!(
242 component,
243 std::path::Component::ParentDir
244 | std::path::Component::RootDir
245 | std::path::Component::Prefix(_)
246 )
247 })
248 {
249 return Err(DomainError::invalid(
250 field,
251 "must be a confined POSIX relative path",
252 ));
253 }
254 Ok(())
255}
256
257fn validate_glob(value: &str) -> Result<(), DomainError> {
258 validate_relative_path("content_scope", value)?;
259 let mut depth = 0_i32;
260 for character in value.chars() {
261 match character {
262 '[' => depth += 1,
263 ']' => depth -= 1,
264 _ => {}
265 }
266 if depth < 0 {
267 return Err(DomainError::invalid(
268 "content_scope",
269 "glob brackets are invalid",
270 ));
271 }
272 }
273 if depth != 0 {
274 return Err(DomainError::invalid(
275 "content_scope",
276 "glob brackets are invalid",
277 ));
278 }
279 Ok(())
280}
281
282fn validate_relation_target(value: &str) -> Result<(), DomainError> {
283 let Some((map_type, directory)) = value.split_once(':') else {
284 return Err(DomainError::invalid(
285 "relations",
286 "targets must use <map_type>:<directory>",
287 ));
288 };
289 if !matches!(map_type, "knowledge" | "codespec") {
290 return Err(DomainError::invalid(
291 "relations",
292 "target map type is invalid",
293 ));
294 }
295 validate_relative_path("relations", directory)
296}
297
298fn unique_values(field: &'static str, values: &[String]) -> Result<(), DomainError> {
299 let mut unique = HashSet::new();
300 for value in values {
301 required_text(field, value.as_str())?;
302 if !unique.insert(value.to_lowercase()) {
303 return Err(DomainError::invalid(field, "values must be unique"));
304 }
305 }
306 Ok(())
307}
308
309fn is_owned_path(map_type: RepositoryMapType, directory: &str, value: &str) -> bool {
310 let prefix = format!("{}/{directory}", map_type.as_str());
311 value == prefix || value.starts_with(&format!("{prefix}/"))
312}
313
314#[cfg(test)]
315#[path = "map_directory_tests.rs"]
316mod tests;