projectatlas_core/
lib.rs

1//! Purpose: Define `ProjectAtlas` 3 core domain models and shared helpers.
2
3pub mod graph;
4pub mod health;
5pub mod index_work;
6pub mod language;
7pub mod optional_parser_pack;
8pub mod optional_parser_protocol;
9pub mod outline;
10pub mod relation_capabilities;
11pub mod support_catalog;
12pub mod symbols;
13pub mod telemetry;
14pub mod toon;
15
16pub use index_work::{
17    IndexCancellation, IndexWorkControl, IndexWorkFailure, IndexWorkResource, IndexWorkStage,
18};
19
20use serde::{Deserialize, Serialize};
21use std::fmt;
22use std::path::{Path, PathBuf, StripPrefixError};
23use thiserror::Error;
24
25/// Core error type for `ProjectAtlas` domain operations.
26#[derive(Debug, Error)]
27pub enum CoreError {
28    /// A path could not be represented relative to the repository root.
29    #[error("path is outside the repository root: {path}")]
30    PathOutsideRoot {
31        /// Path that failed normalization.
32        path: PathBuf,
33        /// Original path-strip error.
34        source: StripPrefixError,
35    },
36    /// A path contains non-UTF-8 data and cannot be stored in the index.
37    #[error("path is not valid UTF-8: {path:?}")]
38    NonUtf8Path {
39        /// Path that could not be converted to UTF-8.
40        path: PathBuf,
41    },
42    /// A user supplied path is not a safe repository-relative file key.
43    #[error("path {path:?} must be a project-relative indexed file path: {reason}")]
44    InvalidRepositoryPath {
45        /// Invalid path.
46        path: PathBuf,
47        /// Human-readable validation reason.
48        reason: &'static str,
49    },
50}
51
52/// Convenient result alias for `ProjectAtlas` core operations.
53pub type CoreResult<T> = Result<T, CoreError>;
54
55/// Monotonic identity of one completely published derived index.
56#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
57#[serde(transparent)]
58pub struct IndexGeneration(u64);
59
60impl IndexGeneration {
61    /// Generation before the first complete publication.
62    pub const ZERO: Self = Self(0);
63
64    /// Construct a generation from its durable integer representation.
65    #[must_use]
66    pub const fn new(value: u64) -> Self {
67        Self(value)
68    }
69
70    /// Return the durable integer representation.
71    #[must_use]
72    pub const fn get(self) -> u64 {
73        self.0
74    }
75
76    /// Advance to the next complete publication generation.
77    #[must_use]
78    pub const fn checked_next(self) -> Option<Self> {
79        match self.0.checked_add(1) {
80            Some(value) => Some(Self(value)),
81            None => None,
82        }
83    }
84}
85
86impl fmt::Display for IndexGeneration {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        self.0.fmt(formatter)
89    }
90}
91
92/// File or folder node kind.
93#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
94#[serde(rename_all = "lowercase")]
95pub enum NodeKind {
96    /// Directory node.
97    Folder,
98    /// File node.
99    File,
100}
101
102impl fmt::Display for NodeKind {
103    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::Folder => formatter.write_str("folder"),
106            Self::File => formatter.write_str("file"),
107        }
108    }
109}
110
111impl NodeKind {
112    /// Parse a database string into a node kind.
113    #[must_use]
114    pub fn from_db(value: &str) -> Option<Self> {
115        match value {
116            "folder" => Some(Self::Folder),
117            "file" => Some(Self::File),
118            _ => None,
119        }
120    }
121}
122
123/// Status for purpose metadata.
124#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "lowercase")]
126pub enum PurposeStatus {
127    /// No purpose exists for this node yet.
128    Missing,
129    /// A generated or heuristic purpose exists but has not been approved.
130    Suggested,
131    /// A purpose has been explicitly approved.
132    Approved,
133    /// A legacy or explicitly flagged accepted purpose awaits explicit review.
134    ///
135    /// Normal source, hash, summary, symbol, and graph changes never create
136    /// this state or demote an approved purpose.
137    Stale,
138}
139
140impl fmt::Display for PurposeStatus {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        formatter.write_str(self.as_str())
143    }
144}
145
146impl PurposeStatus {
147    /// Return the stable database and payload value for this purpose status.
148    #[must_use]
149    pub const fn as_str(self) -> &'static str {
150        match self {
151            Self::Missing => "missing",
152            Self::Suggested => "suggested",
153            Self::Approved => "approved",
154            Self::Stale => "stale",
155        }
156    }
157
158    /// Parse a database string into a purpose status.
159    #[must_use]
160    pub fn from_db(value: &str) -> Option<Self> {
161        match value {
162            value if value == Self::Missing.as_str() => Some(Self::Missing),
163            value if value == Self::Suggested.as_str() => Some(Self::Suggested),
164            value if value == Self::Approved.as_str() => Some(Self::Approved),
165            value if value == Self::Stale.as_str() => Some(Self::Stale),
166            _ => None,
167        }
168    }
169}
170
171/// Source for purpose metadata.
172#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "lowercase")]
174pub enum PurposeSource {
175    /// No source is known yet.
176    Missing,
177    /// Imported from legacy metadata such as `.purpose` or Purpose headers.
178    Imported,
179    /// Generated by a heuristic.
180    Generated,
181    /// Explicitly set by an agent after inspecting enough context.
182    Agent,
183}
184
185impl fmt::Display for PurposeSource {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter.write_str(self.as_str())
188    }
189}
190
191impl PurposeSource {
192    /// Return the stable database and payload value for this purpose source.
193    #[must_use]
194    pub const fn as_str(self) -> &'static str {
195        match self {
196            Self::Missing => "missing",
197            Self::Imported => "imported",
198            Self::Generated => "generated",
199            Self::Agent => "agent",
200        }
201    }
202}
203
204/// Agent-facing priority for purpose curation.
205#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(rename_all = "lowercase")]
207pub enum PurposeReviewPriority {
208    /// Curate during the default folder-first queue.
209    High,
210    /// Skip unless broad file-purpose cleanup was explicitly requested.
211    Low,
212}
213
214impl fmt::Display for PurposeReviewPriority {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            Self::High => formatter.write_str("high"),
218            Self::Low => formatter.write_str("low"),
219        }
220    }
221}
222
223/// Review signal used by agent-facing purpose curation queues.
224#[derive(Clone, Copy, Debug, Eq, PartialEq)]
225pub struct PurposeReviewSignal {
226    /// Priority shown to the agent.
227    pub priority: PurposeReviewPriority,
228    /// Stable reason string explaining why the path is queued.
229    pub reason: &'static str,
230}
231
232/// Repository node stored in the `ProjectAtlas` index.
233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234pub struct Node {
235    /// Repository-relative path using forward slashes.
236    pub path: String,
237    /// File or folder kind.
238    pub kind: NodeKind,
239    /// Parent path using forward slashes.
240    pub parent_path: Option<String>,
241    /// File extension, including the dot.
242    pub extension: Option<String>,
243    /// Detected language or file family.
244    pub language: Option<String>,
245    /// File size in bytes.
246    pub size_bytes: Option<u64>,
247    /// File modification timestamp in nanoseconds since Unix epoch.
248    pub mtime_ns: Option<i64>,
249    /// BLAKE3 hash for file content.
250    pub content_hash: Option<String>,
251}
252
253/// Purpose metadata attached to a node.
254#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255pub struct Purpose {
256    /// Repository-relative node path.
257    pub path: String,
258    /// Purpose one-liner.
259    pub purpose: Option<String>,
260    /// Purpose source.
261    pub source: PurposeSource,
262    /// Purpose lifecycle status.
263    pub status: PurposeStatus,
264}
265
266impl Purpose {
267    /// Return whether this purpose was explicitly approved by an agent.
268    #[must_use]
269    pub fn agent_reviewed(&self) -> bool {
270        self.status == PurposeStatus::Approved && self.source == PurposeSource::Agent
271    }
272}
273
274/// Return the purpose-curation review signal for an indexed node.
275#[must_use]
276pub fn purpose_review_signal(node: &Node, purpose: &Purpose) -> PurposeReviewSignal {
277    if node.kind == NodeKind::Folder {
278        return PurposeReviewSignal {
279            priority: PurposeReviewPriority::High,
280            reason: "folder_navigation",
281        };
282    }
283
284    if node.kind == NodeKind::File
285        && purpose.status == PurposeStatus::Stale
286        && purpose.source == PurposeSource::Agent
287        && is_high_impact_file_path(&node.path)
288    {
289        return PurposeReviewSignal {
290            priority: PurposeReviewPriority::High,
291            reason: "stale_agent_reviewed_file",
292        };
293    }
294
295    if node.kind == NodeKind::File && is_high_impact_file_path(&node.path) {
296        return PurposeReviewSignal {
297            priority: PurposeReviewPriority::High,
298            reason: "high_impact_file",
299        };
300    }
301
302    if node.kind == NodeKind::File && purpose.status == PurposeStatus::Suggested {
303        return PurposeReviewSignal {
304            priority: PurposeReviewPriority::Low,
305            reason: "generated_file_suggestion",
306        };
307    }
308
309    PurposeReviewSignal {
310        priority: PurposeReviewPriority::Low,
311        reason: "selective_file_review",
312    }
313}
314
315/// Return whether a file path is important enough for default purpose curation.
316#[must_use]
317pub fn is_high_impact_file_path(path: &str) -> bool {
318    let normalized = path.replace('\\', "/").to_lowercase();
319    let file_name = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
320    HIGH_IMPACT_FILE_NAMES.contains(&file_name)
321        || HIGH_IMPACT_PATH_PREFIXES
322            .iter()
323            .any(|prefix| normalized.starts_with(prefix))
324        || HIGH_IMPACT_PATH_SEGMENTS
325            .iter()
326            .any(|segment| normalized.contains(segment))
327}
328
329/// File names that belong in default file-purpose curation.
330pub const HIGH_IMPACT_FILE_NAMES: &[&str] = &[
331    "cargo.toml",
332    "package.json",
333    "pyproject.toml",
334    "build.gradle",
335    "build.gradle.kts",
336    "settings.gradle",
337    "settings.gradle.kts",
338    "gradle.properties",
339    "dockerfile",
340    "makefile",
341    "justfile",
342    "main.rs",
343    "lib.rs",
344    "mod.rs",
345    "main.py",
346    "app.py",
347    "server.py",
348    "index.ts",
349    "main.ts",
350    "server.ts",
351    "app.ts",
352    "index.tsx",
353    "app.tsx",
354];
355
356/// Path prefixes that belong in default file-purpose curation.
357pub const HIGH_IMPACT_PATH_PREFIXES: &[&str] = &[".github/workflows/"];
358
359/// Path segments that belong in default file-purpose curation.
360pub const HIGH_IMPACT_PATH_SEGMENTS: &[&str] = &["/migrations/", "/routes/", "/commands/", "/mcp"];
361
362/// Legacy stored source value used by older approved human-curated purposes.
363pub const LEGACY_HUMAN_PURPOSE_SOURCE: &str = "human";
364
365/// Stored purpose source values that represent reviewed agent-owned purposes.
366pub const AGENT_REVIEWED_SOURCE_VALUES: &[&str] =
367    &[PurposeSource::Agent.as_str(), LEGACY_HUMAN_PURPOSE_SOURCE];
368
369/// A node with attached purpose state.
370#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
371pub struct IndexedNode {
372    /// Node metadata.
373    pub node: Node,
374    /// Purpose metadata.
375    pub purpose: Purpose,
376    /// One-line observed content summary for this node.
377    pub summary: Option<String>,
378}
379
380/// Compact deterministic reasons used by agent-facing repository ranking.
381#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
382#[serde(rename_all = "snake_case")]
383pub enum RankedReasonCode {
384    /// The normalized query exactly selected the repository path.
385    ExactPath,
386    /// The normalized query exactly selected the final path component.
387    ExactName,
388    /// An agent-approved responsibility purpose matched the query.
389    ReviewedPurpose,
390    /// Repository path text contributed weaker lexical evidence.
391    Path,
392    /// Observed summary text contributed weaker lexical evidence.
393    Summary,
394    /// An indexed symbol contributed weaker lexical evidence.
395    Symbol,
396    /// Persisted source text contributed weaker lexical evidence.
397    IndexedText,
398    /// A conventional source or test counterpart was present.
399    PairedFile,
400    /// Current package/dependency context contributed graph evidence.
401    GraphPackage,
402    /// Current import context contributed graph evidence.
403    GraphImport,
404    /// Current call context contributed graph evidence.
405    GraphCall,
406    /// Current reference context contributed graph evidence.
407    GraphReference,
408    /// Current test context contributed graph evidence.
409    GraphTest,
410    /// Current route context contributed graph evidence.
411    GraphRoute,
412    /// Current configuration context contributed graph evidence.
413    GraphConfig,
414}
415
416/// Closed connection families exposed by folder and file navigation rows.
417#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
418#[serde(rename_all = "snake_case")]
419pub enum RankedConnectionKind {
420    /// Package or manifest dependency context.
421    Package,
422    /// Source import context.
423    Import,
424    /// Static call context.
425    Call,
426    /// Static reference context.
427    Reference,
428    /// Test-to-source context.
429    Test,
430    /// Route or protocol context.
431    Route,
432    /// Configuration context.
433    Config,
434}
435
436/// Direction of one sampled relationship relative to the ranked node.
437#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
438#[serde(rename_all = "snake_case")]
439pub enum RankedConnectionDirection {
440    /// The ranked node owns the relation source.
441    Outbound,
442    /// The ranked node owns the resolved relation target.
443    Inbound,
444}
445
446/// Compact typed target for a sampled ranked-node connection.
447#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
448#[serde(tag = "kind", rename_all = "snake_case")]
449pub enum RankedConnectionTarget {
450    /// A repository-local file or declaration.
451    Local {
452        /// Exact repository-relative source path.
453        path: String,
454        /// Declaration name when the target is a symbol.
455        symbol: Option<String>,
456    },
457    /// A manifest-owned package identity.
458    Package {
459        /// Package ecosystem or manifest family.
460        manager: String,
461        /// Package name declared by the manifest.
462        name: String,
463        /// Exact repository-relative owning manifest.
464        manifest: String,
465    },
466    /// A typed target outside the selected repository.
467    External {
468        /// External namespace.
469        system: String,
470        /// Identity inside the external namespace.
471        identity: String,
472    },
473    /// A static reference that could not be resolved uniquely.
474    Unresolved {
475        /// Bounded reference identity retained by graph persistence.
476        reference: String,
477    },
478}
479
480/// One bounded high-value connection sampled for a ranked node.
481#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
482pub struct RankedConnection {
483    /// Closed relationship family.
484    pub kind: RankedConnectionKind,
485    /// Direction relative to the ranked node.
486    pub direction: RankedConnectionDirection,
487    /// Typed compact target or source at the other end.
488    pub target: RankedConnectionTarget,
489}
490
491/// Bounded count metadata for one connection family.
492#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
493pub struct RankedConnectionCount {
494    /// Closed relationship family.
495    pub kind: RankedConnectionKind,
496    /// Number of validated rows observed inside the family bound.
497    pub count: usize,
498    /// Whether at least one additional row exists for this family.
499    pub truncated: bool,
500}
501
502/// Existing navigation capability recommended after one ranked row.
503#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
504#[serde(rename_all = "snake_case")]
505pub enum NavigationNextCapability {
506    /// Narrow a selected folder to indexed files.
507    Files,
508    /// Inspect one selected file summary.
509    Summary,
510    /// Inspect detailed typed relations after a connection sample truncates.
511    Relations,
512    /// Inspect bounded structural or coverage health for the selected path.
513    Health,
514}
515
516/// Directly reusable next navigation call for one ranked row.
517#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
518pub struct NavigationNextCall {
519    /// Existing capability to invoke next.
520    pub capability: NavigationNextCapability,
521    /// Exact repository-relative path accepted by that capability.
522    pub path: String,
523}
524
525/// A ranked node with concise evidence for why it was selected.
526#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
527pub struct RankedNode {
528    /// Selected indexed node.
529    pub node: IndexedNode,
530    /// Bounded human-readable ranking signals.
531    pub reasons: Vec<String>,
532    /// Bounded stable ranking signals for programmatic consumers.
533    pub reason_codes: Vec<RankedReasonCode>,
534    /// Sparse stable-order connection counts.
535    pub connection_counts: Vec<RankedConnectionCount>,
536    /// Bounded high-value current connection sample.
537    pub connections: Vec<RankedConnection>,
538    /// Whether the bounded sample omitted any validated relation through family or global overflow.
539    pub connections_truncated: bool,
540    /// Existing navigation capability recommended after this row.
541    pub next_call: NavigationNextCall,
542}
543
544/// Overview returned by startup/overview commands.
545#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
546pub struct Overview {
547    /// Number of indexed files.
548    pub files: usize,
549    /// Number of indexed folders.
550    pub folders: usize,
551    /// Number of missing purpose entries.
552    pub missing_purposes: usize,
553    /// Number of stale purpose entries.
554    pub stale_purposes: usize,
555    /// Number of approved purpose entries.
556    pub approved_purposes: usize,
557    /// Number of suggested purpose entries.
558    pub suggested_purposes: usize,
559}
560
561/// Convert an absolute path into a stable repository-relative slash path.
562///
563/// # Errors
564///
565/// Returns an error when `path` is outside `root` or cannot be represented as
566/// UTF-8.
567pub fn normalize_repo_path(root: &Path, path: &Path) -> CoreResult<String> {
568    let relative = path
569        .strip_prefix(root)
570        .map_err(|source| CoreError::PathOutsideRoot {
571            path: path.to_path_buf(),
572            source,
573        })?;
574    if relative.as_os_str().is_empty() {
575        return Ok(".".to_string());
576    }
577    let as_str = relative.to_str().ok_or_else(|| CoreError::NonUtf8Path {
578        path: relative.to_path_buf(),
579    })?;
580    Ok(as_str.replace('\\', "/"))
581}
582
583/// Normalize a native filesystem path for stable diagnostics and metadata.
584///
585/// The returned path uses forward slashes, strips Windows extended path
586/// prefixes such as `\\?\`, and converts extended UNC paths to `//server/share`
587/// form. This helper is for persisted metadata and agent-facing output; use
588/// `Path`/`PathBuf` for host filesystem access.
589#[must_use]
590pub fn normalize_native_path_display(path: impl AsRef<Path>) -> String {
591    normalize_native_path_display_str(&path.as_ref().to_string_lossy())
592}
593
594/// Normalize a native filesystem path string for stable diagnostics and metadata.
595///
596/// This string-oriented variant exists for values read from metadata or tests
597/// before they are converted back into a platform `Path`.
598#[must_use]
599pub fn normalize_native_path_display_str(path: &str) -> String {
600    let normalized = path.replace('\\', "/");
601    if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
602        format!("//{rest}")
603    } else if let Some(rest) = normalized.strip_prefix("//?/") {
604        rest.to_string()
605    } else {
606        normalized
607    }
608}
609
610/// Normalize and validate a user-supplied path as a repository-relative file key.
611///
612/// # Errors
613///
614/// Returns an error when `file` is absolute, uses a Windows drive prefix,
615/// contains parent traversal, is empty, or cannot be represented as UTF-8.
616pub fn validated_repo_file_key(file: &Path) -> CoreResult<String> {
617    let key = validated_repo_node_key(file)?;
618    if key == "." {
619        return Err(CoreError::InvalidRepositoryPath {
620            path: file.to_path_buf(),
621            reason: "a file path is required",
622        });
623    }
624    Ok(key)
625}
626
627/// Normalize and validate a user-supplied path as a repository-relative node key.
628///
629/// Unlike [`validated_repo_file_key`], this accepts `.` for the repository root
630/// folder so purpose metadata can be set on either folders or files.
631///
632/// # Errors
633///
634/// Returns an error when `file` is absolute, uses a Windows drive prefix,
635/// contains parent traversal, is empty, or cannot be represented as UTF-8.
636pub fn validated_repo_node_key(file: &Path) -> CoreResult<String> {
637    let raw = file
638        .to_str()
639        .ok_or_else(|| CoreError::NonUtf8Path {
640            path: file.to_path_buf(),
641        })?
642        .replace('\\', "/");
643    if raw.trim().is_empty() {
644        return Err(CoreError::InvalidRepositoryPath {
645            path: file.to_path_buf(),
646            reason: "a path is required",
647        });
648    }
649    if raw.starts_with('/') || raw.starts_with("//") || has_windows_drive_prefix(&raw) {
650        return Err(CoreError::InvalidRepositoryPath {
651            path: file.to_path_buf(),
652            reason: "absolute paths are not allowed",
653        });
654    }
655    let mut parts = Vec::new();
656    for component in raw.split('/') {
657        match component {
658            "" | "." => {}
659            ".." => {
660                return Err(CoreError::InvalidRepositoryPath {
661                    path: file.to_path_buf(),
662                    reason: "parent traversal is not allowed",
663                });
664            }
665            part => parts.push(part.to_string()),
666        }
667    }
668    if parts.is_empty() {
669        return Ok(".".to_string());
670    }
671    Ok(parts.join("/"))
672}
673
674/// Convert a stable slash-separated repository key into a native path.
675#[must_use]
676pub fn repo_path_to_native(path: &str) -> PathBuf {
677    path.split('/').fold(PathBuf::new(), |mut native, part| {
678        native.push(part);
679        native
680    })
681}
682
683/// Normalize a repository-relative path prefix used by query filters.
684///
685/// This helper accepts `.` and empty prefixes because filter callers often use
686/// them to mean the repository root. Exact file reads should still use
687/// [`validated_repo_file_key`] so absolute paths and traversal are rejected.
688#[must_use]
689pub fn normalize_repo_path_prefix(value: &str) -> String {
690    let normalized = value
691        .replace('\\', "/")
692        .trim()
693        .trim_start_matches("./")
694        .trim_end_matches('/')
695        .to_string();
696    if normalized.is_empty() {
697        ".".to_string()
698    } else {
699        normalized
700    }
701}
702
703/// Return whether normalized text starts with a Windows drive prefix.
704fn has_windows_drive_prefix(path: &str) -> bool {
705    let bytes = path.as_bytes();
706    bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
707}
708
709/// Return the parent path for a normalized repository path.
710#[must_use]
711pub fn normalized_parent(path: &str) -> Option<String> {
712    if path == "." {
713        return None;
714    }
715    let parent = Path::new(path).parent()?;
716    if parent.as_os_str().is_empty() {
717        Some(".".to_string())
718    } else {
719        Some(parent.to_string_lossy().replace('\\', "/"))
720    }
721}
722
723/// Return a normalized extension for indexing.
724#[must_use]
725pub fn normalized_extension(path: &Path) -> Option<String> {
726    language::normalized_language_extension(path)
727}
728
729#[cfg(test)]
730mod tests {
731    use super::{
732        Node, NodeKind, Purpose, PurposeReviewPriority, PurposeSource, PurposeStatus,
733        is_high_impact_file_path, normalize_native_path_display_str, normalize_repo_path_prefix,
734        normalized_parent, purpose_review_signal, repo_path_to_native, validated_repo_file_key,
735        validated_repo_node_key,
736    };
737    use std::io;
738    use std::path::Path;
739
740    #[test]
741    fn validated_repo_file_key_normalizes_safe_relative_paths()
742    -> Result<(), Box<dyn std::error::Error>> {
743        require_eq(
744            &validated_repo_file_key(Path::new("src\\main.rs"))?,
745            "src/main.rs",
746        )?;
747        require_eq(
748            &validated_repo_file_key(Path::new("./src/lib.rs"))?,
749            "src/lib.rs",
750        )?;
751        Ok(())
752    }
753
754    #[test]
755    fn validated_repo_file_key_rejects_absolute_and_parent_paths() {
756        assert!(validated_repo_file_key(Path::new("../secret.rs")).is_err());
757        assert!(validated_repo_file_key(Path::new("C:/secret.rs")).is_err());
758        assert!(validated_repo_file_key(Path::new("/secret.rs")).is_err());
759        assert!(validated_repo_file_key(Path::new(".")).is_err());
760    }
761
762    #[test]
763    fn validated_repo_node_key_accepts_root_and_relative_paths()
764    -> Result<(), Box<dyn std::error::Error>> {
765        require_eq(&validated_repo_node_key(Path::new("."))?, ".")?;
766        require_eq(&validated_repo_node_key(Path::new("./src"))?, "src")?;
767        require_eq(
768            &validated_repo_node_key(Path::new("src\\main.rs"))?,
769            "src/main.rs",
770        )?;
771        Ok(())
772    }
773
774    #[test]
775    fn validated_repo_node_key_rejects_empty_paths() {
776        assert!(validated_repo_node_key(Path::new("")).is_err());
777        assert!(validated_repo_node_key(Path::new("   ")).is_err());
778    }
779
780    #[test]
781    fn repo_path_to_native_builds_platform_path_components() {
782        assert_eq!(
783            repo_path_to_native("src/main.rs"),
784            Path::new("src").join("main.rs")
785        );
786    }
787
788    #[test]
789    fn normalize_repo_path_prefix_accepts_root_and_slashes() {
790        assert_eq!(normalize_repo_path_prefix(""), ".");
791        assert_eq!(normalize_repo_path_prefix("."), ".");
792        assert_eq!(normalize_repo_path_prefix(".\\docs\\api\\"), "docs/api");
793        assert_eq!(normalize_repo_path_prefix("./src/lib"), "src/lib");
794    }
795
796    #[test]
797    fn purpose_review_signal_is_folder_first_and_file_selective() {
798        let folder = test_node("src", NodeKind::Folder);
799        let file = test_node("src/helper.rs", NodeKind::File);
800        let build_file = test_node("build.gradle.kts", NodeKind::File);
801        let suggested = Purpose {
802            path: "src/helper.rs".to_string(),
803            purpose: Some("Generated helper suggestion".to_string()),
804            source: PurposeSource::Generated,
805            status: PurposeStatus::Suggested,
806        };
807        let approved = Purpose {
808            path: "src".to_string(),
809            purpose: Some("Rust source folder".to_string()),
810            source: PurposeSource::Agent,
811            status: PurposeStatus::Approved,
812        };
813        let stale = Purpose {
814            path: "src/helper.rs".to_string(),
815            purpose: Some("Reviewed helper implementation".to_string()),
816            source: PurposeSource::Agent,
817            status: PurposeStatus::Stale,
818        };
819
820        let folder_signal = purpose_review_signal(&folder, &approved);
821        assert_eq!(folder_signal.priority, PurposeReviewPriority::High);
822        assert_eq!(folder_signal.reason, "folder_navigation");
823
824        let file_signal = purpose_review_signal(&file, &suggested);
825        assert_eq!(file_signal.priority, PurposeReviewPriority::Low);
826        assert_eq!(file_signal.reason, "generated_file_suggestion");
827
828        let build_signal = purpose_review_signal(&build_file, &suggested);
829        assert_eq!(build_signal.priority, PurposeReviewPriority::High);
830        assert_eq!(build_signal.reason, "high_impact_file");
831
832        let low_stale_signal = purpose_review_signal(&file, &stale);
833        assert_eq!(low_stale_signal.priority, PurposeReviewPriority::Low);
834        assert_eq!(low_stale_signal.reason, "selective_file_review");
835
836        let high_stale_signal = purpose_review_signal(&build_file, &stale);
837        assert_eq!(high_stale_signal.priority, PurposeReviewPriority::High);
838        assert_eq!(high_stale_signal.reason, "stale_agent_reviewed_file");
839        assert!(is_high_impact_file_path(".github/workflows/release.yml"));
840    }
841
842    #[test]
843    fn native_path_display_removes_windows_extended_prefixes() {
844        assert_eq!(
845            normalize_native_path_display_str(r"\\?\C:\repo\.projectatlas\projectatlas.db"),
846            "C:/repo/.projectatlas/projectatlas.db"
847        );
848        assert_eq!(
849            normalize_native_path_display_str(r"\\?\UNC\server\share\repo"),
850            "//server/share/repo"
851        );
852        assert_eq!(
853            normalize_native_path_display_str("/home/user/repo"),
854            "/home/user/repo"
855        );
856        assert_eq!(
857            normalize_native_path_display_str("src\\main.rs"),
858            "src/main.rs"
859        );
860    }
861
862    fn require_eq(left: &str, right: &str) -> Result<(), Box<dyn std::error::Error>> {
863        if left == right {
864            Ok(())
865        } else {
866            Err(io::Error::other(format!("expected {right:?}, found {left:?}")).into())
867        }
868    }
869
870    fn test_node(path: &str, kind: NodeKind) -> Node {
871        Node {
872            path: path.to_string(),
873            kind,
874            parent_path: normalized_parent(path),
875            extension: None,
876            language: None,
877            size_bytes: None,
878            mtime_ns: None,
879            content_hash: None,
880        }
881    }
882}