Skip to main content

relay_knowledge/domain/knowledge/
map_directory.rs

1use std::{collections::HashSet, path::Path};
2
3use serde::{Deserialize, Serialize};
4
5use super::{DomainError, error::required_text};
6
7/// Repository navigation map selected by `relay-knowledge map --type`.
8#[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/// When an agent should load content described by a directory entry.
32#[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/// How authored content in a governed directory is updated.
41#[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/// Supported typed relationship between governed directories.
50#[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/// Qualified relation target, such as `codespec:design`.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct DirectoryRelation {
64    pub kind: DirectoryRelationKind,
65    pub target: String,
66}
67
68/// Strongly typed directory governance entry persisted in a repository map.
69#[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/// Optional fields accepted by `map directory update`.
129#[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;