1pub 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#[derive(Debug, Error)]
27pub enum CoreError {
28 #[error("path is outside the repository root: {path}")]
30 PathOutsideRoot {
31 path: PathBuf,
33 source: StripPrefixError,
35 },
36 #[error("path is not valid UTF-8: {path:?}")]
38 NonUtf8Path {
39 path: PathBuf,
41 },
42 #[error("path {path:?} must be a project-relative indexed file path: {reason}")]
44 InvalidRepositoryPath {
45 path: PathBuf,
47 reason: &'static str,
49 },
50}
51
52pub type CoreResult<T> = Result<T, CoreError>;
54
55#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
57#[serde(transparent)]
58pub struct IndexGeneration(u64);
59
60impl IndexGeneration {
61 pub const ZERO: Self = Self(0);
63
64 #[must_use]
66 pub const fn new(value: u64) -> Self {
67 Self(value)
68 }
69
70 #[must_use]
72 pub const fn get(self) -> u64 {
73 self.0
74 }
75
76 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
94#[serde(rename_all = "lowercase")]
95pub enum NodeKind {
96 Folder,
98 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 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "lowercase")]
126pub enum PurposeStatus {
127 Missing,
129 Suggested,
131 Approved,
133 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 #[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 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "lowercase")]
174pub enum PurposeSource {
175 Missing,
177 Imported,
179 Generated,
181 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 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(rename_all = "lowercase")]
207pub enum PurposeReviewPriority {
208 High,
210 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
225pub struct PurposeReviewSignal {
226 pub priority: PurposeReviewPriority,
228 pub reason: &'static str,
230}
231
232#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234pub struct Node {
235 pub path: String,
237 pub kind: NodeKind,
239 pub parent_path: Option<String>,
241 pub extension: Option<String>,
243 pub language: Option<String>,
245 pub size_bytes: Option<u64>,
247 pub mtime_ns: Option<i64>,
249 pub content_hash: Option<String>,
251}
252
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255pub struct Purpose {
256 pub path: String,
258 pub purpose: Option<String>,
260 pub source: PurposeSource,
262 pub status: PurposeStatus,
264}
265
266impl Purpose {
267 #[must_use]
269 pub fn agent_reviewed(&self) -> bool {
270 self.status == PurposeStatus::Approved && self.source == PurposeSource::Agent
271 }
272}
273
274#[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#[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
329pub 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
356pub const HIGH_IMPACT_PATH_PREFIXES: &[&str] = &[".github/workflows/"];
358
359pub const HIGH_IMPACT_PATH_SEGMENTS: &[&str] = &["/migrations/", "/routes/", "/commands/", "/mcp"];
361
362pub const LEGACY_HUMAN_PURPOSE_SOURCE: &str = "human";
364
365pub const AGENT_REVIEWED_SOURCE_VALUES: &[&str] =
367 &[PurposeSource::Agent.as_str(), LEGACY_HUMAN_PURPOSE_SOURCE];
368
369#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
371pub struct IndexedNode {
372 pub node: Node,
374 pub purpose: Purpose,
376 pub summary: Option<String>,
378}
379
380#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
382#[serde(rename_all = "snake_case")]
383pub enum RankedReasonCode {
384 ExactPath,
386 ExactName,
388 ReviewedPurpose,
390 Path,
392 Summary,
394 Symbol,
396 IndexedText,
398 PairedFile,
400 GraphPackage,
402 GraphImport,
404 GraphCall,
406 GraphReference,
408 GraphTest,
410 GraphRoute,
412 GraphConfig,
414}
415
416#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
418#[serde(rename_all = "snake_case")]
419pub enum RankedConnectionKind {
420 Package,
422 Import,
424 Call,
426 Reference,
428 Test,
430 Route,
432 Config,
434}
435
436#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
438#[serde(rename_all = "snake_case")]
439pub enum RankedConnectionDirection {
440 Outbound,
442 Inbound,
444}
445
446#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
448#[serde(tag = "kind", rename_all = "snake_case")]
449pub enum RankedConnectionTarget {
450 Local {
452 path: String,
454 symbol: Option<String>,
456 },
457 Package {
459 manager: String,
461 name: String,
463 manifest: String,
465 },
466 External {
468 system: String,
470 identity: String,
472 },
473 Unresolved {
475 reference: String,
477 },
478}
479
480#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
482pub struct RankedConnection {
483 pub kind: RankedConnectionKind,
485 pub direction: RankedConnectionDirection,
487 pub target: RankedConnectionTarget,
489}
490
491#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
493pub struct RankedConnectionCount {
494 pub kind: RankedConnectionKind,
496 pub count: usize,
498 pub truncated: bool,
500}
501
502#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
504#[serde(rename_all = "snake_case")]
505pub enum NavigationNextCapability {
506 Files,
508 Summary,
510 Relations,
512 Health,
514}
515
516#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
518pub struct NavigationNextCall {
519 pub capability: NavigationNextCapability,
521 pub path: String,
523}
524
525#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
527pub struct RankedNode {
528 pub node: IndexedNode,
530 pub reasons: Vec<String>,
532 pub reason_codes: Vec<RankedReasonCode>,
534 pub connection_counts: Vec<RankedConnectionCount>,
536 pub connections: Vec<RankedConnection>,
538 pub connections_truncated: bool,
540 pub next_call: NavigationNextCall,
542}
543
544#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
546pub struct Overview {
547 pub files: usize,
549 pub folders: usize,
551 pub missing_purposes: usize,
553 pub stale_purposes: usize,
555 pub approved_purposes: usize,
557 pub suggested_purposes: usize,
559}
560
561pub 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#[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#[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
610pub 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
627pub 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#[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#[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
703fn 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#[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#[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}