Skip to main content

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