1mod graph_projection;
5mod module_resolution;
6#[cfg(feature = "optional-parser-supervisor")]
7mod optional_parser_runtime;
8mod source_observation;
9
10pub(crate) use source_observation::{
11 SourceObservationRegistry, VerifiedReadOutcome, VerifiedReadStamp,
12};
13
14use crate::atlas_map::{
15 self, init_project_with_config, load_atlas_config, load_atlas_config_for_root,
16 load_atlas_config_from_text,
17};
18use crate::structural::{
19 document_summary_from_facts, is_scanner_fallback_summary, is_structural_summary_candidate,
20 markdown_summary_from_facts, structural_summary_for_path,
21};
22use crate::{
23 CliError, OutputFormat, WATCH_MODE_NOTIFY, WATCH_MODE_ONCE, WATCH_MODE_POLLING, truthy_env,
24};
25use blake3::Hasher;
26use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
27#[cfg(feature = "optional-parser-supervisor")]
28use projectatlas_cli::optional_parser_lifecycle::{
29 OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH, OptionalParserPackLifecycle,
30 OptionalParserPackLifecycleReport, OptionalParserPackProjectSelection,
31};
32use projectatlas_core::graph::{ExtendedRelationKind, GraphRelationKind, ProjectInstanceId};
33use projectatlas_core::health::{
34 CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
35 CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
36 HealthFinding, Severity,
37};
38use projectatlas_core::language::{
39 ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION, ContentClassification, ContentSelection,
40 LANGUAGE_CAPABILITY_REGISTRY_VERSION, LanguageRegistryReport, SymbolParserOwner,
41 accepted_language_capability_digest, content_classification, language_capability,
42 language_registry_digest, language_registry_report,
43};
44#[cfg(all(test, feature = "optional-parser-supervisor"))]
45use projectatlas_core::optional_parser_pack::OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION;
46use projectatlas_core::outline::estimate_tokens;
47use projectatlas_core::relation_capabilities::{
48 RelationFamilyInventoryReport, relation_family_inventory_report,
49};
50use projectatlas_core::symbols::{
51 ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
52};
53use projectatlas_core::telemetry::{
54 TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
55 TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
56 UsageInstanceId, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
57};
58use projectatlas_core::toon::{encode_agent_payload, render_ranked_node_rows, render_symbol_rows};
59use projectatlas_core::{
60 CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure,
61 IndexWorkResource, IndexWorkStage, Node, NodeKind, Overview, PurposeSource, PurposeStatus,
62 normalize_native_path_display, normalize_native_path_display_str, normalize_repo_path,
63 purpose_review_signal, repo_path_to_native, validated_repo_file_key, validated_repo_node_key,
64};
65use projectatlas_db::{
66 AtlasStore, ClassifiedSymbol, DatabasePublicationContractState, DatabasePublicationReport,
67 DatabaseSchemaCompatibility, DatabaseSettingsReport, DbResult, FileContentClassification,
68 HealthFindingsPage, HealthQuery, HealthScope, IndexPublication, IndexPublicationGuard,
69 IndexPublicationState, IndexedFileText, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
70 MAX_PURPOSE_CURATION_BATCH_ROWS, PurposeConditionalApplyRequest, PurposeConditionalApplyState,
71 TelemetryRetentionState, WorktreeRegistration, WorktreeUsageSnapshot, database_settings_report,
72 preflight_project_binding_read_only, read_legacy_project_root_candidate_read_only,
73 read_project_root_identity_read_only, validate_database_location,
74};
75use projectatlas_fs::worktree::{
76 GitManagerSourceSelection, GitRepositorySelection, GitWorktreeState, RepositoryStructure,
77 git_administrative_identity, git_worktree_lifecycle_matches,
78};
79use projectatlas_fs::{
80 FsError, RootScanPolicy, ScanLimits, ScanOptions, gitignore_excludes_path,
81 scan_path_with_policy_controlled, scan_repo, scan_repo_controlled,
82 scan_repo_controlled_with_work,
83};
84use projectatlas_service::{
85 ClassifiedRankedNode, CoverageDiscoveryReport, FederatedInputWork, FederatedStore,
86 FilePathMatcher, MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, NextStepReport,
87 TokenReport, TokenReportRequest,
88 build_next_report_with_selection as build_next_report_with_selection_service,
89 load_agent_efficiency_comparison,
90 load_classified_ranked_file_nodes_with_reasons as load_classified_ranked_file_nodes_with_reasons_service,
91 load_ranked_file_nodes_with_reasons, load_ranked_folder_nodes_with_reasons,
92 validate_federated_root_count,
93};
94use projectatlas_symbols::{
95 DocumentExtractionError, DocumentLimit, MAX_DOCUMENT_COMPRESSED_BYTES, MarkdownFacts,
96 document_format_for_path, extract_document_symbol_facts_controlled,
97 extract_document_text_controlled, extract_markdown_facts_controlled,
98 extract_symbol_graph_with_source_controlled, semantic_resolution_contract_digest,
99};
100use rayon::ThreadPoolBuilder;
101use rayon::prelude::*;
102use serde::{Deserialize, Serialize};
103use serde_json::{Value, json};
104use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
105use std::fmt;
106use std::fmt::Write as _;
107use std::fs;
108use std::io::{self, Read};
109use std::path::{Path, PathBuf};
110use std::sync::Arc;
111use std::sync::atomic::{AtomicBool, Ordering};
112use std::sync::mpsc::{self, RecvTimeoutError, TrySendError};
113use std::thread;
114use std::time::{Duration, Instant};
115
116pub(crate) const MAX_SYMBOL_FILE_BYTES: u64 = 2_000_000;
118pub(crate) const DEFAULT_HEALTH_LIMIT: usize = 50;
120pub(crate) const MAX_HEALTH_LIMIT: usize = 200;
122pub(crate) const MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES: u64 = 2 * 1_024 * 1_024;
124pub(crate) const PURPOSE_CURATOR_RECOMMENDED_REASONING: &str = "lowest_reliable_host_supported";
126const DEFAULT_INDEX_WORK_TIMEOUT: Duration = Duration::from_mins(30);
128const MAX_STAGED_TEXT_BYTES: u64 = 512 * 1024 * 1024;
130const MAX_PUBLICATION_STAGING_BYTES: u64 = 2 * 1024 * 1024 * 1024;
132const PUBLICATION_NODE_BATCH_SIZE: usize = 1_024;
134const PUBLICATION_PATH_BATCH_SIZE: usize = 128;
136const PUBLICATION_TEXT_BATCH_SIZE: usize = 32;
138#[cfg(not(feature = "optional-parser-supervisor"))]
140const SYMBOL_PARSE_BATCH_SIZE: usize = 64;
141const MAX_SYMBOL_PARSE_JOBS: usize = 100_000;
143const MAX_INCREMENTAL_CHANGED_PATHS: usize = 100_000;
145const MAX_INCREMENTAL_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
147const WATCH_EVENT_QUEUE_CAPACITY: usize = 1_024;
149pub(crate) const INDEX_WORKER_SAFE_CEILING: usize = 32;
151const CONTROLLED_SOURCE_READ_BUFFER_BYTES: usize = 8_192;
153const MAX_PURPOSE_IMPORT_BYTES: u64 = 512 * 1_024 * 1_024;
155const MAX_PURPOSE_INPUT_FILE_BYTES: u64 = 16 * 1_024 * 1_024;
157const MAX_PURPOSE_REVIEW_PATH_BYTES: usize = 4 * 1_024;
159const MAX_PURPOSE_REVIEW_FIELD_BYTES: usize = 64 * 1_024;
161const PURPOSE_REVIEW_REPORT_ERROR_FIELD: &str = "error";
163const MAX_PURPOSE_REVIEW_INPUT_BYTES: usize = 512 * 1_024;
165const MAX_PURPOSE_REVIEW_REPORT_BYTES: usize = 4 * 1_024 * 1_024;
167const MAX_PURPOSE_HEADER_BYTES: u64 = 256 * 1_024;
169const MAX_PURPOSE_IMPORT_RECORDS: u64 = 1_000_000;
171
172const BUILTIN_PROJECTATLAS_PURPOSES: &[(&str, &str)] = &[
174 (
175 ".projectatlas",
176 "Store project-local ProjectAtlas metadata, configuration, and runtime state.",
177 ),
178 (
179 ".projectatlas/config.toml",
180 "Configure project-local ProjectAtlas scan, lint, purpose, and output policy.",
181 ),
182 (
183 ".projectatlas/projectatlas-nonsource-files.toon",
184 "Declare project-local non-source file purposes for ProjectAtlas map compatibility.",
185 ),
186 (
187 ".projectatlas/projectatlas-purpose-review.json",
188 "Replay agent-reviewed ProjectAtlas purpose records into the local SQLite index.",
189 ),
190];
191const CORE_INDEX_POLICY_PATHS: &[&str] = &[".projectatlas/config.toml", "projectatlas.toml"];
193
194pub(crate) struct ScanRuntimePlan {
196 pub(crate) root: PathBuf,
198 pub(crate) config: Option<atlas_map::AtlasMapConfig>,
200 selected_config_path: Option<PathBuf>,
202 config_path_override: Option<PathBuf>,
204 pub(crate) scan_options: ScanOptions,
206 pub(crate) text_options: TextIndexOptions,
208 text_index_max_bytes_override: Option<u64>,
210 #[cfg(feature = "optional-parser-supervisor")]
212 optional_parser_selection: OptionalParserPackProjectSelection,
213}
214
215struct PurposeImportSnapshot {
217 records: Vec<atlas_map::ImportedPurposeRecord>,
219 fingerprint: String,
221}
222
223#[derive(Clone, Copy)]
225struct PurposeImportLimits {
226 total_bytes: u64,
228 complete_file_bytes: u64,
230 header_bytes: u64,
232 records: u64,
234}
235
236impl Default for PurposeImportLimits {
237 fn default() -> Self {
238 Self {
239 total_bytes: MAX_PURPOSE_IMPORT_BYTES,
240 complete_file_bytes: MAX_PURPOSE_INPUT_FILE_BYTES,
241 header_bytes: MAX_PURPOSE_HEADER_BYTES,
242 records: MAX_PURPOSE_IMPORT_RECORDS,
243 }
244 }
245}
246
247struct PurposeInputReader<'a> {
249 control: &'a IndexWorkControl,
251 limits: PurposeImportLimits,
253 complete_paths: BTreeSet<PathBuf>,
255 purpose_filename: String,
257 complete_digests: BTreeMap<PathBuf, String>,
259}
260
261const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8;
263const NORMAL_READ_REFRESH_MAX_PATHS: usize = 64;
265const NORMAL_READ_REFRESH_MAX_BYTES: u64 = 8 * 1024 * 1024;
267const MAX_INDEXED_NAVIGATION_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
269const INDEX_DERIVATION_CONTRACT_VERSION: &str = "3";
271
272#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "snake_case")]
275pub(crate) enum IndexReadStatus {
276 InitRequired,
278 WorktreeRequired,
280 RefreshRequired,
282 VerificationIncomplete,
284 ProjectMismatch,
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
290#[serde(rename_all = "snake_case")]
291pub(crate) enum IndexRefreshReason {
292 SourceChanged,
294 PathsChanged,
296 PolicyDrift,
298 DependencyClosureLimit,
300}
301
302#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
304#[serde(rename_all = "snake_case")]
305pub(crate) enum IndexRefreshScope {
306 Incremental,
308 Full,
310}
311
312struct IndexFreshnessDelta {
314 report: IndexRefreshRequired,
316 paths: HashSet<PathBuf>,
318}
319
320#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
322pub(crate) struct SourceVerificationWork {
323 pub(crate) filesystem_entries: u64,
325 pub(crate) filesystem_bytes: u64,
327 pub(crate) sqlite_read_statements: u64,
329 pub(crate) decoded_nodes: u64,
331}
332
333struct IndexFreshnessAssessment {
335 delta: Option<IndexFreshnessDelta>,
337 work: SourceVerificationWork,
339}
340
341pub(crate) struct ExactFreshIndexRead {
343 pub(crate) store: AtlasStore,
345 pub(crate) work: SourceVerificationWork,
347}
348
349#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
351#[serde(rename_all = "snake_case")]
352pub(crate) enum IndexVerificationReason {
353 PolicyUnavailable,
355 SourceInspectionFailed,
357 SourceTooLarge,
359 ProjectIdentityUnavailable,
361 PublicationIncomplete,
363 PublicationContractMismatch,
365}
366
367#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
369pub(crate) struct IndexInitRequired {
370 pub(crate) project_root: Option<String>,
372 pub(crate) database: Option<String>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub(crate) worktree: Option<String>,
377 pub(crate) status: IndexReadStatus,
379}
380
381#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
383pub(crate) struct ProjectWorktreeRequired {
384 pub(crate) project_root: Option<String>,
386 pub(crate) status: IndexReadStatus,
388}
389
390#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
392pub(crate) struct IndexRefreshRequired {
393 pub(crate) project_root: Option<String>,
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub(crate) worktree: Option<String>,
398 pub(crate) status: IndexReadStatus,
400 pub(crate) reason: IndexRefreshReason,
402 pub(crate) scope: IndexRefreshScope,
404 pub(crate) changed: usize,
406 pub(crate) added: usize,
408 pub(crate) removed: usize,
410 pub(crate) modified: usize,
412 pub(crate) sample_paths: Vec<String>,
414}
415
416#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
418pub(crate) struct IndexVerificationIncomplete {
419 pub(crate) project_root: Option<String>,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub(crate) worktree: Option<String>,
424 pub(crate) status: IndexReadStatus,
426 pub(crate) reason: IndexVerificationReason,
428 pub(crate) scope: IndexRefreshScope,
430 pub(crate) message: String,
432}
433
434#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
436pub(crate) struct IndexProjectMismatch {
437 pub(crate) status: IndexReadStatus,
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub(crate) worktree: Option<String>,
442 pub(crate) selected_project_root: Option<String>,
447 pub(crate) indexed_project_root: Option<String>,
452 #[serde(skip)]
454 diagnostic: Option<String>,
455}
456
457impl IndexProjectMismatch {
458 pub(crate) fn from_native_roots(
460 selected: &CanonicalProjectRoot,
461 indexed: &CanonicalProjectRoot,
462 ) -> Self {
463 Self {
464 status: IndexReadStatus::ProjectMismatch,
465 worktree: None,
466 selected_project_root: selected.display_string().ok(),
467 indexed_project_root: indexed.display_string().ok(),
468 diagnostic: None,
469 }
470 }
471}
472
473impl fmt::Display for IndexRefreshRequired {
474 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
475 let recovery = self.worktree.as_ref().map_or_else(
476 || "run `projectatlas watch --once` or `atlas_watch_once` before retrying".to_string(),
477 |alias| format!("call `atlas_watch_once` with `worktree: {alias}` before retrying"),
478 );
479 if self.reason == IndexRefreshReason::PolicyDrift {
480 return write!(
481 formatter,
482 "refresh_required: derived index policy differs from the current project configuration; {recovery}"
483 );
484 }
485 if self.reason == IndexRefreshReason::DependencyClosureLimit {
486 let recovery = self.worktree.as_ref().map_or_else(
487 || "run a complete `projectatlas scan` or `atlas_scan` before retrying".to_string(),
488 |alias| format!("call `atlas_scan` with `worktree: {alias}` before retrying"),
489 );
490 return write!(
491 formatter,
492 "refresh_required: the dependency-aware incremental closure exceeded its safe limit; {recovery}"
493 );
494 }
495 write!(
496 formatter,
497 "refresh_required: {} indexed path(s) differ from current local source; {recovery}",
498 self.changed,
499 )
500 }
501}
502
503impl fmt::Display for IndexInitRequired {
504 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
505 if let Some(alias) = self.worktree.as_ref() {
506 return write!(
507 formatter,
508 "init_required: ProjectAtlas index '{}' is missing for registered worktree '{}'; call `atlas_init` with `worktree: {alias}`",
509 self.database
510 .as_deref()
511 .unwrap_or("<native display unavailable>"),
512 alias,
513 );
514 }
515 write!(
516 formatter,
517 "init_required: ProjectAtlas index '{}' is missing for selected project root '{}'; run `projectatlas init` from that exact root or call `atlas_init` with that exact `project_path`",
518 self.database
519 .as_deref()
520 .unwrap_or("<native display unavailable>"),
521 self.project_root
522 .as_deref()
523 .unwrap_or("<native display unavailable>")
524 )
525 }
526}
527
528impl fmt::Display for ProjectWorktreeRequired {
529 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
530 write!(
531 formatter,
532 "worktree_required: '{}' is a bare/common Git directory without checked-out source; select a checked-out worktree and initialize that exact root",
533 self.project_root
534 .as_deref()
535 .unwrap_or("<native display unavailable>")
536 )
537 }
538}
539
540impl fmt::Display for IndexVerificationIncomplete {
541 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
542 write!(
543 formatter,
544 "verification_incomplete: current local source could not be verified safely: {}",
545 self.message
546 )
547 }
548}
549
550impl fmt::Display for IndexProjectMismatch {
551 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
552 if let Some(diagnostic) = self.diagnostic.as_deref() {
553 return formatter.write_str(diagnostic);
554 }
555 write!(
556 formatter,
557 "project_mismatch: selected project root '{}' does not match index root '{}'",
558 self.selected_project_root
559 .as_deref()
560 .unwrap_or("<native display unavailable>"),
561 self.indexed_project_root
562 .as_deref()
563 .unwrap_or("<native display unavailable>"),
564 )
565 }
566}
567
568#[cfg(test)]
570fn verify_index_freshness(
571 store: &AtlasStore,
572 root: &Path,
573 config_path: Option<&Path>,
574) -> Result<(), CliError> {
575 let plan = ScanRuntimePlan::for_path(config_path, root, None).map_err(|source| {
576 verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &source)
577 })?;
578 match detect_index_freshness(store, &plan)? {
579 Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
580 None => Ok(()),
581 }
582}
583
584pub(crate) fn open_fresh_atlas_store_for_project(
586 db_path: &Path,
587 root: &Path,
588 config_path: Option<&Path>,
589) -> Result<AtlasStore, CliError> {
590 let control = standalone_index_work_control();
591 open_fresh_atlas_store_for_project_controlled(db_path, root, config_path, &control)
592}
593
594pub(crate) fn open_fresh_atlas_store_for_project_controlled(
596 db_path: &Path,
597 root: &Path,
598 config_path: Option<&Path>,
599 control: &IndexWorkControl,
600) -> Result<AtlasStore, CliError> {
601 Ok(
602 open_exact_fresh_atlas_store_for_project_controlled(db_path, root, config_path, control)?
603 .store,
604 )
605}
606
607pub(crate) fn open_exact_fresh_atlas_store_for_project_controlled(
609 db_path: &Path,
610 root: &Path,
611 config_path: Option<&Path>,
612 control: &IndexWorkControl,
613) -> Result<ExactFreshIndexRead, CliError> {
614 open_exact_fresh_atlas_store_for_project_with_repair(
615 db_path,
616 root,
617 config_path,
618 control,
619 true,
620 ScanLimits::default(),
621 )
622}
623
624pub(crate) fn verify_saved_source_matches_index_controlled(
626 db_path: &Path,
627 root: &Path,
628 config_path: Option<&Path>,
629 control: &IndexWorkControl,
630) -> Result<(), CliError> {
631 let exact =
632 open_exact_saved_source_matches_index_controlled(db_path, root, config_path, control)?;
633 exact.store.finish_index_read_snapshot()?;
634 Ok(())
635}
636
637pub(crate) fn open_exact_saved_source_matches_index_controlled(
639 db_path: &Path,
640 root: &Path,
641 config_path: Option<&Path>,
642 control: &IndexWorkControl,
643) -> Result<ExactFreshIndexRead, CliError> {
644 open_exact_fresh_atlas_store_for_project_with_repair(
645 db_path,
646 root,
647 config_path,
648 control,
649 false,
650 ScanLimits::default(),
651 )
652}
653
654fn open_exact_fresh_atlas_store_for_project_with_repair(
656 db_path: &Path,
657 root: &Path,
658 config_path: Option<&Path>,
659 control: &IndexWorkControl,
660 repair_safe_delta: bool,
661 scan_limits: ScanLimits,
662) -> Result<ExactFreshIndexRead, CliError> {
663 let bounded_control = bounded_index_work_control(control);
664 let control = &bounded_control;
665 let store = open_atlas_store_read_only_for_project(db_path, root)?;
666 let plan = ScanRuntimePlan::for_path_controlled(config_path, root, None, control)
667 .map_err(|source| publication_input_error(root, source))?;
668 let assessment = match detect_index_freshness_controlled(&store, &plan, scan_limits, control) {
669 Ok(assessment) => assessment,
670 Err(CliError::VerificationIncomplete(report))
671 if report.reason == IndexVerificationReason::PublicationContractMismatch =>
672 {
673 return Err(CliError::RefreshRequired(Box::new(
674 index_policy_refresh_required(&plan.root),
675 )));
676 }
677 Err(error) => return Err(error),
678 };
679 let mut work = assessment.work;
680 let Some(delta) = assessment.delta else {
681 return Ok(ExactFreshIndexRead { store, work });
682 };
683 if !repair_safe_delta {
684 return Err(CliError::RefreshRequired(Box::new(delta.report)));
685 }
686 if delta.report.scope != IndexRefreshScope::Incremental {
687 return Err(CliError::RefreshRequired(Box::new(delta.report)));
688 }
689
690 let refresh_required = delta.report.clone();
691 drop(store);
692 let repair = (|| {
693 let mut writer = open_atlas_store_for_project(db_path, &plan.root)?;
694 let changes = WatchChangeSet {
695 requires_full_scan: false,
696 document_paths: delta.paths.clone(),
697 paths: delta.paths,
698 };
699 refresh_index_for_changes_controlled(
700 &mut writer,
701 &plan,
702 &changes,
703 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
704 control,
705 )
706 })();
707 if let Err(error) = repair {
708 if automatic_refresh_write_is_unavailable(&error) {
709 return Err(CliError::RefreshRequired(Box::new(refresh_required)));
710 }
711 return Err(error);
712 }
713
714 let store = open_atlas_store_read_only_for_project(db_path, &plan.root)?;
715 verify_index_project_root(&store, &plan.root)?;
716 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
717 verify_index_publication(&store, &plan)?;
718 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
719 Ok(ExactFreshIndexRead { store, work })
720}
721
722pub(crate) fn open_federated_atlas_stores_for_project(
724 selected_db: &Path,
725 selected_root: &Path,
726 selected_config: Option<&Path>,
727 roots: &[PathBuf],
728 worktrees: Option<&[String]>,
729 control: &IndexWorkControl,
730) -> Result<Vec<FederatedStore>, CliError> {
731 validate_federated_root_count(roots.len()).map_err(CliError::Service)?;
732 if worktrees.is_some_and(|worktrees| worktrees.len() != roots.len()) {
733 return Err(CliError::Service(
734 projectatlas_service::ServiceError::InvalidInput(
735 "federated worktree labels must match the ordered root count".to_string(),
736 ),
737 ));
738 }
739 let selected_root = fs::canonicalize(selected_root).map_err(|source| CliError::Io {
740 path: selected_root.to_path_buf(),
741 source,
742 })?;
743 let mut canonical_roots = Vec::with_capacity(roots.len());
744 for root in roots {
745 let root = fs::canonicalize(root).map_err(|source| CliError::Io {
746 path: root.clone(),
747 source,
748 })?;
749 if canonical_roots.contains(&root) {
750 return Err(CliError::Service(
751 projectatlas_service::ServiceError::InvalidInput(
752 "federated roots must be unique".to_string(),
753 ),
754 ));
755 }
756 canonical_roots.push(root);
757 }
758 if canonical_roots.first() != Some(&selected_root) {
759 return Err(CliError::Service(
760 projectatlas_service::ServiceError::InvalidInput(
761 "the first federated root must be the selected project root".to_string(),
762 ),
763 ));
764 }
765
766 let databases = canonical_roots
767 .iter()
768 .enumerate()
769 .map(|(order, root)| {
770 if order == 0 {
771 selected_db.to_path_buf()
772 } else {
773 root.join(".projectatlas").join("projectatlas.db")
774 }
775 })
776 .collect::<Vec<_>>();
777 let mut database_bytes = 0_u64;
778 for database in &databases {
779 let metadata = fs::metadata(database).map_err(|source| CliError::Io {
780 path: database.clone(),
781 source,
782 })?;
783 if !metadata.is_file() {
784 return Err(CliError::Service(
785 projectatlas_service::ServiceError::InvalidInput(
786 "federated database path is not a regular file".to_string(),
787 ),
788 ));
789 }
790 database_bytes = database_bytes.checked_add(metadata.len()).ok_or_else(|| {
791 CliError::Service(projectatlas_service::ServiceError::InvalidInput(
792 "participating database byte count overflowed".to_string(),
793 ))
794 })?;
795 if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
796 return Err(CliError::Service(
797 projectatlas_service::ServiceError::InvalidInput(format!(
798 "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
799 )),
800 ));
801 }
802 }
803
804 let default_scan_limits = ScanLimits::default();
805 let mut remaining_input_bytes = MAX_FEDERATED_INPUT_BYTES;
806 let mut stores: Vec<FederatedStore> = Vec::with_capacity(canonical_roots.len());
807 for (order, (root, database)) in canonical_roots.into_iter().zip(databases).enumerate() {
808 let started = Instant::now();
809 let config = (order == 0).then_some(selected_config).flatten();
810 let exact = match open_exact_fresh_atlas_store_for_project_with_repair(
811 &database,
812 &root,
813 config,
814 control,
815 false,
816 ScanLimits::new(
817 default_scan_limits.max_entries(),
818 remaining_input_bytes,
819 default_scan_limits.max_workers(),
820 ),
821 ) {
822 Ok(exact) => exact,
823 Err(error) => {
824 for store in stores {
825 drop(store.finish());
826 }
827 let error =
828 if let Some(worktree) = worktrees.and_then(|worktrees| worktrees.get(order)) {
829 federated_worktree_error(error, worktree)
830 } else {
831 error
832 };
833 return Err(error);
834 }
835 };
836 remaining_input_bytes = remaining_input_bytes.saturating_sub(exact.work.filesystem_bytes);
837 let input_work = FederatedInputWork {
838 filesystem_entries: exact.work.filesystem_entries,
839 filesystem_bytes: exact.work.filesystem_bytes,
840 sqlite_read_statements: exact.work.sqlite_read_statements,
841 decoded_nodes: exact.work.decoded_nodes,
842 elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
843 };
844 let worktree = worktrees.map(|worktrees| worktrees[order].clone());
845 match FederatedStore::new_with_worktree(exact.store, database, root, input_work, worktree) {
846 Ok(store) => stores.push(store),
847 Err(error) => {
848 for store in stores {
849 drop(store.finish());
850 }
851 return Err(CliError::Service(error));
852 }
853 }
854 }
855 Ok(stores)
856}
857
858fn with_synchronized_registered_worktree_usage<T>(
860 control_db: &Path,
861 control_root: &Path,
862 expected_control_project: Option<ProjectInstanceId>,
863 read: impl FnOnce(&AtlasStore) -> DbResult<T>,
864) -> Result<T, CliError> {
865 synchronize_registered_worktree_usage_with_catalog_validation(
866 control_db,
867 control_root,
868 expected_control_project,
869 |_, _| Ok(()),
870 || Ok(()),
871 read,
872 )
873}
874
875pub(crate) fn load_synchronized_repository_token_report(
877 control_db: &Path,
878 control_root: &Path,
879 expected_control_project: Option<ProjectInstanceId>,
880 request: TokenReportRequest<'_>,
881) -> Result<TokenReport, CliError> {
882 let (benchmark_results, trend_window) = match request {
883 TokenReportRequest::RepositoryOverview { benchmark_results } => (benchmark_results, None),
884 TokenReportRequest::RepositoryTrends { window } => (None, Some(window)),
885 TokenReportRequest::Overview { .. } | TokenReportRequest::Trends { .. } => {
886 return Err(CliError::InvalidInput(
887 "synchronized token reports require repository scope".to_string(),
888 ));
889 }
890 };
891 let mut report = with_synchronized_registered_worktree_usage(
892 control_db,
893 control_root,
894 expected_control_project,
895 |control| match trend_window {
896 None => Ok(TokenReport::Overview(Box::new(
897 control.repository_token_overview()?,
898 ))),
899 Some(window) => Ok(TokenReport::Trends(
900 control.repository_token_trends(window)?,
901 )),
902 },
903 )?;
904 if let (Some(benchmark_results), TokenReport::Overview(overview)) =
905 (benchmark_results, &mut report)
906 {
907 let control = open_atlas_store_read_only_for_project(control_db, control_root)?;
908 overview.set_agent_efficiency(load_agent_efficiency_comparison(
909 &control,
910 Some(benchmark_results),
911 )?);
912 }
913 Ok(report)
914}
915
916#[cfg(test)]
918pub(crate) fn synchronize_registered_worktree_usage(
919 control_db: &Path,
920 control_root: &Path,
921 expected_control_project: Option<ProjectInstanceId>,
922) -> Result<(), CliError> {
923 with_synchronized_registered_worktree_usage(
924 control_db,
925 control_root,
926 expected_control_project,
927 |_| Ok(()),
928 )
929}
930
931fn synchronize_registered_worktree_usage_with_catalog_validation<T>(
933 control_db: &Path,
934 control_root: &Path,
935 expected_control_project: Option<ProjectInstanceId>,
936 mut before_snapshot_revalidation: impl FnMut(&WorktreeRegistration, &Path) -> Result<(), CliError>,
937 before_catalog_validation: impl FnOnce() -> Result<(), CliError>,
938 read: impl FnOnce(&AtlasStore) -> DbResult<T>,
939) -> Result<T, CliError> {
940 let control_reader = open_atlas_store_read_only_for_project(control_db, control_root)?;
941 let control_project =
942 require_synchronization_control_identity(&control_reader, expected_control_project)?;
943 let mut registrations = control_reader.worktree_registrations(false)?;
944 drop(control_reader);
945 if registrations.is_empty() {
946 before_catalog_validation()?;
947 let control = open_atlas_store_for_project(control_db, control_root)?;
948 require_synchronization_control_identity(&control, Some(control_project))?;
949 return match control.with_matching_active_worktree_catalog(®istrations, || read(&control))?
950 {
951 Some(value) => Ok(value),
952 None => Err(CliError::InvalidInput(
953 "registered worktree catalog changed during aggregate synchronization; retry the token report"
954 .to_string(),
955 )),
956 };
957 }
958 let repository = match projectatlas_fs::worktree::discover_repository_structure(control_root)? {
959 RepositoryStructure::Git(repository) => repository,
960 RepositoryStructure::NonGit { selected_root } => {
961 return Err(CliError::InvalidInput(format!(
962 "registered worktree synchronization requires Git control evidence at '{}'",
963 normalize_native_path_display(selected_root)
964 )));
965 }
966 RepositoryStructure::InvalidGit {
967 selected_root,
968 issue,
969 } => {
970 return Err(CliError::InvalidInput(format!(
971 "registered worktree synchronization found invalid Git evidence for '{}': {:?} at '{}'",
972 normalize_native_path_display(selected_root),
973 issue.kind,
974 normalize_native_path_display(issue.path)
975 )));
976 }
977 };
978 let control = open_atlas_store_for_project(control_db, control_root)?;
979 require_synchronization_control_identity(&control, Some(control_project))?;
980 let common = CanonicalProjectRoot::from_path(&repository.common_directory)
981 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
982 let active_roots = repository
983 .worktrees
984 .iter()
985 .filter_map(|entry| match &entry.state {
986 GitWorktreeState::Active { root, .. } => {
987 CanonicalProjectRoot::from_path(&entry.administrative_directory)
988 .ok()
989 .map(|administrative_directory| (administrative_directory, root.as_path()))
990 }
991 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => None,
992 })
993 .collect::<HashMap<_, _>>();
994 for registration in &mut registrations {
995 let synchronization_incomplete = || {
996 CliError::InvalidInput(format!(
997 "registered worktree '{}': its bound local atlas is unavailable, so aggregate token totals cannot be synchronized",
998 registration.alias
999 ))
1000 };
1001 if registration.git_common_directory_identity != common {
1002 if registration.project_instance_id.is_some() {
1003 return Err(synchronization_incomplete());
1004 }
1005 continue;
1006 }
1007 let root = active_roots
1008 .get(®istration.git_administrative_directory_identity)
1009 .copied()
1010 .filter(|_| {
1011 git_administrative_identity(
1012 registration.git_administrative_directory_identity.as_path(),
1013 )
1014 .is_ok_and(|identity| identity == registration.git_administrative_identity)
1015 });
1016 let Some(root) = root else {
1017 if registration.project_instance_id.is_some() {
1018 return Err(synchronization_incomplete());
1019 }
1020 continue;
1021 };
1022 let synchronized_project = control.with_active_worktree_registration(
1023 registration.registration_id,
1024 ®istration.alias,
1025 |guard| {
1026 if let Err(error) =
1027 require_registered_worktree_lifecycle(guard.registration(), root)
1028 {
1029 return Ok(Err(error));
1030 }
1031 let database = root.join(".projectatlas").join("projectatlas.db");
1032 match fs::symlink_metadata(&database) {
1033 Ok(_) => {}
1034 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1035 return Ok(if guard.registration().project_instance_id.is_some() {
1036 Err(synchronization_incomplete())
1037 } else {
1038 Ok(None)
1039 });
1040 }
1041 Err(error) => return Ok(Err(error.into())),
1042 }
1043 let target = match open_atlas_store_read_only_for_project(&database, root) {
1044 Ok(target) => target,
1045 Err(error) => return Ok(Err(error)),
1046 };
1047 let snapshot = match target.export_worktree_usage_snapshot() {
1048 Ok(snapshot) => snapshot,
1049 Err(error) => return Ok(Err(error.into())),
1050 };
1051 drop(target);
1052 if let Err(error) = before_snapshot_revalidation(guard.registration(), root) {
1053 return Ok(Err(error));
1054 }
1055 if let Err(error) =
1056 require_registered_worktree_lifecycle(guard.registration(), root)
1057 {
1058 return Ok(Err(error));
1059 }
1060 if let Err(error) =
1061 require_current_worktree_usage_snapshot(&database, root, &snapshot)
1062 {
1063 return Ok(Err(error));
1064 }
1065 let project = snapshot.project_instance_id();
1066 if guard.registration().project_instance_id.is_none() {
1067 guard.bind_project_with_usage_snapshot(root, project, &snapshot)?;
1068 } else {
1069 guard.synchronize_usage_snapshot(&snapshot)?;
1070 }
1071 Ok(Ok(Some(project)))
1072 },
1073 )??;
1074 if let Some(project) = synchronized_project {
1075 registration.project_instance_id = Some(project);
1076 }
1077 }
1078 before_catalog_validation()?;
1079 match control.with_matching_active_worktree_catalog(®istrations, || read(&control))? {
1080 Some(value) => Ok(value),
1081 None => Err(CliError::InvalidInput(
1082 "registered worktree catalog changed during aggregate synchronization; retry the token report"
1083 .to_string(),
1084 )),
1085 }
1086}
1087
1088pub(crate) fn require_current_worktree_usage_snapshot(
1090 database: &Path,
1091 root: &Path,
1092 expected: &WorktreeUsageSnapshot,
1093) -> Result<(), CliError> {
1094 let current = open_atlas_store_read_only_for_project(database, root)?;
1095 if current.export_worktree_usage_snapshot()? != *expected {
1096 return Err(CliError::InvalidInput(
1097 "worktree atlas changed after its usage snapshot was captured; retry the operation"
1098 .to_string(),
1099 ));
1100 }
1101 Ok(())
1102}
1103
1104pub(crate) fn require_registered_worktree_lifecycle(
1106 registration: &WorktreeRegistration,
1107 expected_root: &Path,
1108) -> Result<(), CliError> {
1109 let lifecycle_changed = || {
1110 CliError::InvalidInput(format!(
1111 "registered worktree '{}': its Git administrative lifecycle changed before aggregate token synchronization",
1112 registration.alias
1113 ))
1114 };
1115 if !git_worktree_lifecycle_matches(
1116 expected_root,
1117 registration.git_common_directory_identity.as_path(),
1118 registration.git_administrative_directory_identity.as_path(),
1119 ®istration.git_administrative_identity,
1120 )? {
1121 return Err(lifecycle_changed());
1122 }
1123 Ok(())
1124}
1125
1126fn require_synchronization_control_identity(
1128 control: &AtlasStore,
1129 expected: Option<ProjectInstanceId>,
1130) -> Result<ProjectInstanceId, CliError> {
1131 let observed = control.project_instance_id()?.ok_or_else(|| {
1132 CliError::InvalidInput(
1133 "control atlas has no project identity for registered worktree synchronization"
1134 .to_string(),
1135 )
1136 })?;
1137 if expected.is_some_and(|expected| expected != observed) {
1138 return Err(CliError::InvalidInput(
1139 "control atlas identity changed before registered worktree synchronization".to_string(),
1140 ));
1141 }
1142 Ok(observed)
1143}
1144
1145pub(crate) fn federated_worktree_error(mut error: CliError, worktree: &str) -> CliError {
1147 match &mut error {
1148 CliError::InitRequired(report) => report.worktree = Some(worktree.to_string()),
1149 CliError::RefreshRequired(report) => report.worktree = Some(worktree.to_string()),
1150 CliError::VerificationIncomplete(report) => report.worktree = Some(worktree.to_string()),
1151 CliError::ProjectMismatch(report) => report.worktree = Some(worktree.to_string()),
1152 _ => {
1153 return CliError::InvalidInput(format!(
1154 "federated worktree {worktree:?} failed: {error}"
1155 ));
1156 }
1157 }
1158 error
1159}
1160
1161fn automatic_refresh_write_is_unavailable(error: &CliError) -> bool {
1163 matches!(error, CliError::Db(source) if source.is_write_unavailable())
1164}
1165
1166fn index_policy_refresh_required(root: &Path) -> IndexRefreshRequired {
1168 IndexRefreshRequired {
1169 project_root: lossless_project_root_display(root),
1170 worktree: None,
1171 status: IndexReadStatus::RefreshRequired,
1172 reason: IndexRefreshReason::PolicyDrift,
1173 scope: IndexRefreshScope::Full,
1174 changed: 0,
1175 added: 0,
1176 removed: 0,
1177 modified: 0,
1178 sample_paths: Vec::new(),
1179 }
1180}
1181
1182#[cfg(test)]
1184fn detect_index_freshness(
1185 store: &AtlasStore,
1186 plan: &ScanRuntimePlan,
1187) -> Result<Option<IndexFreshnessDelta>, CliError> {
1188 let control = standalone_index_work_control();
1189 Ok(detect_index_freshness_controlled(store, plan, ScanLimits::default(), &control)?.delta)
1190}
1191
1192fn detect_index_freshness_controlled(
1194 store: &AtlasStore,
1195 plan: &ScanRuntimePlan,
1196 scan_limits: ScanLimits,
1197 control: &IndexWorkControl,
1198) -> Result<IndexFreshnessAssessment, CliError> {
1199 let mut work = SourceVerificationWork::default();
1200 verify_index_project_root(store, &plan.root)?;
1201 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1202 verify_index_publication(store, plan)?;
1203 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1204 let scan = scan_repo_controlled_with_work(&plan.root, &plan.scan_options, scan_limits, control)
1205 .map_err(|source| source_inspection_error(&plan.root, source))?;
1206 work.filesystem_entries = scan.work.entries;
1207 work.filesystem_bytes = scan.work.source_bytes;
1208 let current_nodes = scan.nodes;
1209 let indexed_nodes = store
1210 .load_nodes()?
1211 .into_iter()
1212 .map(|indexed| indexed.node)
1213 .collect::<Vec<_>>();
1214 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1215 work.decoded_nodes = u64::try_from(indexed_nodes.len()).unwrap_or(u64::MAX);
1216 Ok(IndexFreshnessAssessment {
1217 delta: source_node_delta(&plan.root, ¤t_nodes, &indexed_nodes),
1218 work,
1219 })
1220}
1221
1222fn verify_source_nodes_match(
1224 root: &Path,
1225 current_nodes: &[Node],
1226 indexed_nodes: &[Node],
1227) -> Result<(), CliError> {
1228 match source_node_delta(root, current_nodes, indexed_nodes) {
1229 Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
1230 None => Ok(()),
1231 }
1232}
1233
1234fn source_node_delta(
1236 root: &Path,
1237 current_nodes: &[Node],
1238 indexed_nodes: &[Node],
1239) -> Option<IndexFreshnessDelta> {
1240 let current_by_path = current_nodes
1241 .iter()
1242 .map(|node| (node.path.as_str(), node))
1243 .collect::<BTreeMap<_, _>>();
1244 let indexed_by_path = indexed_nodes
1245 .iter()
1246 .map(|node| (node.path.as_str(), node))
1247 .collect::<BTreeMap<_, _>>();
1248
1249 let added_paths = current_by_path
1250 .keys()
1251 .filter(|path| !indexed_by_path.contains_key(**path))
1252 .copied()
1253 .collect::<Vec<_>>();
1254 let removed_paths = indexed_by_path
1255 .keys()
1256 .filter(|path| !current_by_path.contains_key(**path))
1257 .copied()
1258 .collect::<Vec<_>>();
1259 let mut modified_paths = Vec::new();
1260 for (path, current) in ¤t_by_path {
1261 let Some(indexed) = indexed_by_path.get(path) else {
1262 continue;
1263 };
1264 if !same_indexed_source(current, indexed) {
1265 modified_paths.push(*path);
1266 }
1267 }
1268 let changed = added_paths
1269 .len()
1270 .saturating_add(removed_paths.len())
1271 .saturating_add(modified_paths.len());
1272 if changed == 0 {
1273 return None;
1274 }
1275
1276 let changed_paths = added_paths
1277 .iter()
1278 .chain(&removed_paths)
1279 .chain(&modified_paths)
1280 .copied()
1281 .collect::<BTreeSet<_>>()
1282 .into_iter()
1283 .collect::<Vec<_>>();
1284 let changed_bytes = added_paths
1285 .iter()
1286 .chain(&modified_paths)
1287 .filter_map(|path| current_by_path.get(path).and_then(|node| node.size_bytes))
1288 .fold(0_u64, u64::saturating_add);
1289 let requires_full_scan = changed > NORMAL_READ_REFRESH_MAX_PATHS
1290 || changed_bytes > NORMAL_READ_REFRESH_MAX_BYTES
1291 || changed_paths
1292 .iter()
1293 .any(|path| watch_path_requires_full_scan(root, &root.join(repo_path_to_native(path))));
1294 let sample_paths = changed_paths
1295 .iter()
1296 .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
1297 .map(|path| (*path).to_string())
1298 .collect();
1299 Some(IndexFreshnessDelta {
1300 report: IndexRefreshRequired {
1301 project_root: lossless_project_root_display(root),
1302 worktree: None,
1303 status: IndexReadStatus::RefreshRequired,
1304 reason: if added_paths.is_empty() && removed_paths.is_empty() {
1305 IndexRefreshReason::SourceChanged
1306 } else {
1307 IndexRefreshReason::PathsChanged
1308 },
1309 scope: if requires_full_scan {
1310 IndexRefreshScope::Full
1311 } else {
1312 IndexRefreshScope::Incremental
1313 },
1314 changed,
1315 added: added_paths.len(),
1316 removed: removed_paths.len(),
1317 modified: modified_paths.len(),
1318 sample_paths,
1319 },
1320 paths: changed_paths
1321 .into_iter()
1322 .map(|path| root.join(repo_path_to_native(path)))
1323 .collect(),
1324 })
1325}
1326
1327fn verify_index_project_root(store: &AtlasStore, selected_root: &Path) -> Result<(), CliError> {
1329 let Some(indexed_root) = store.project_root_identity()? else {
1330 return Err(verification_incomplete(
1331 selected_root,
1332 IndexVerificationReason::ProjectIdentityUnavailable,
1333 &CliError::InvalidInput("index project root metadata is missing".to_string()),
1334 ));
1335 };
1336 let selected_root = CanonicalProjectRoot::from_path(selected_root).map_err(|source| {
1337 verification_incomplete(
1338 selected_root,
1339 IndexVerificationReason::SourceInspectionFailed,
1340 &CliError::InvalidInput(source.to_string()),
1341 )
1342 })?;
1343 if !store.project_root_identity_matches(&selected_root) {
1344 return Err(CliError::ProjectMismatch(Box::new(
1345 IndexProjectMismatch::from_native_roots(&selected_root, &indexed_root),
1346 )));
1347 }
1348 Ok(())
1349}
1350
1351fn verify_index_publication(store: &AtlasStore, plan: &ScanRuntimePlan) -> Result<(), CliError> {
1353 let expected_fingerprint = plan.publication_contract_fingerprint();
1354 let Some(publication) = store.index_publication()? else {
1355 return Err(verification_incomplete(
1356 &plan.root,
1357 IndexVerificationReason::PublicationIncomplete,
1358 &CliError::InvalidInput(
1359 "derived index publication state is missing; run one refresh".to_string(),
1360 ),
1361 ));
1362 };
1363 if publication.state == IndexPublicationState::Updating {
1364 return Err(verification_incomplete(
1365 &plan.root,
1366 IndexVerificationReason::PublicationIncomplete,
1367 &CliError::InvalidInput(
1368 "a prior derived index publication did not complete; run one refresh".to_string(),
1369 ),
1370 ));
1371 }
1372 if publication.generation == projectatlas_core::IndexGeneration::ZERO {
1373 return Err(verification_incomplete(
1374 &plan.root,
1375 IndexVerificationReason::PublicationIncomplete,
1376 &CliError::InvalidInput(
1377 "derived index has no complete publication generation; run one refresh".to_string(),
1378 ),
1379 ));
1380 }
1381 if publication.contract_fingerprint.as_deref() != Some(expected_fingerprint.as_str()) {
1382 return Err(verification_incomplete(
1383 &plan.root,
1384 IndexVerificationReason::PublicationContractMismatch,
1385 &CliError::InvalidInput(
1386 "derived index parser or scan-policy contract changed; run one refresh".to_string(),
1387 ),
1388 ));
1389 }
1390 Ok(())
1391}
1392
1393fn publication_contract_matches(
1395 store: &AtlasStore,
1396 plan: &ScanRuntimePlan,
1397) -> Result<bool, CliError> {
1398 let Some(publication) = store.index_publication()? else {
1399 return Ok(false);
1400 };
1401 Ok(publication.state == IndexPublicationState::Complete
1402 && publication.generation != IndexGeneration::ZERO
1403 && publication.contract_fingerprint.as_deref()
1404 == Some(plan.publication_contract_fingerprint().as_str()))
1405}
1406
1407fn index_derivation_fingerprint(
1409 scan_options: &ScanOptions,
1410 text_options: TextIndexOptions,
1411 #[cfg(feature = "optional-parser-supervisor")]
1412 optional_parser_selection: &OptionalParserPackProjectSelection,
1413) -> String {
1414 index_derivation_fingerprint_for_contract(
1415 scan_options,
1416 text_options,
1417 #[cfg(feature = "optional-parser-supervisor")]
1418 optional_parser_selection,
1419 INDEX_DERIVATION_CONTRACT_VERSION,
1420 &semantic_resolution_contract_digest(),
1421 )
1422}
1423
1424fn index_derivation_fingerprint_for_contract(
1426 scan_options: &ScanOptions,
1427 text_options: TextIndexOptions,
1428 #[cfg(feature = "optional-parser-supervisor")]
1429 optional_parser_selection: &OptionalParserPackProjectSelection,
1430 contract_version: &str,
1431 semantic_resolution_digest: &str,
1432) -> String {
1433 let mut hasher = Hasher::new();
1434 hash_index_contract_value(&mut hasher, "contract_version", contract_version);
1435 hash_index_contract_value(
1436 &mut hasher,
1437 "language_registry_version",
1438 &LANGUAGE_CAPABILITY_REGISTRY_VERSION.to_string(),
1439 );
1440 hash_index_contract_value(
1441 &mut hasher,
1442 "accepted_language_set_version",
1443 &ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION.to_string(),
1444 );
1445 hash_index_contract_value(
1446 &mut hasher,
1447 "language_registry_digest",
1448 &language_registry_digest(),
1449 );
1450 hash_index_contract_value(
1451 &mut hasher,
1452 "accepted_language_set_digest",
1453 &accepted_language_capability_digest(),
1454 );
1455 hash_index_contract_value(
1456 &mut hasher,
1457 "semantic_resolution_contract_digest",
1458 semantic_resolution_digest,
1459 );
1460 for value in &scan_options.exclude_dir_names {
1461 hash_index_contract_value(&mut hasher, "exclude_dir_name", value);
1462 }
1463 for value in &scan_options.exclude_dir_suffixes {
1464 hash_index_contract_value(&mut hasher, "exclude_dir_suffix", value);
1465 }
1466 for value in &scan_options.exclude_path_prefixes {
1467 hash_index_contract_value(&mut hasher, "exclude_path_prefix", value);
1468 }
1469 for (selector, language) in &scan_options.language_overrides {
1470 hash_index_contract_value(&mut hasher, "language_override_selector", selector);
1471 hash_index_contract_value(&mut hasher, "language_override_target", language);
1472 }
1473 hash_index_contract_value(
1474 &mut hasher,
1475 "text_index_max_bytes",
1476 &text_options.max_bytes.to_string(),
1477 );
1478 #[cfg(feature = "optional-parser-supervisor")]
1479 hash_index_contract_value(
1480 &mut hasher,
1481 "optional_parser_selection",
1482 optional_parser_selection
1483 .selection_key()
1484 .map_or("inactive", |selection| selection.as_str()),
1485 );
1486 hasher.finalize().to_hex().to_string()
1487}
1488
1489#[cfg(test)]
1491fn revalidate_index_publication_inputs_controlled(
1492 store: &AtlasStore,
1493 plan: &ScanRuntimePlan,
1494 expected_purpose_import_fingerprint: Option<&str>,
1495 control: &IndexWorkControl,
1496) -> Result<(), CliError> {
1497 revalidate_index_publication_inputs_controlled_with_limits(
1498 store,
1499 plan,
1500 expected_purpose_import_fingerprint,
1501 control,
1502 PurposeImportLimits::default(),
1503 )
1504}
1505
1506#[cfg(test)]
1508fn revalidate_index_publication_inputs_controlled_with_limits(
1509 store: &AtlasStore,
1510 plan: &ScanRuntimePlan,
1511 expected_purpose_import_fingerprint: Option<&str>,
1512 control: &IndexWorkControl,
1513 purpose_limits: PurposeImportLimits,
1514) -> Result<(), CliError> {
1515 let staged_nodes = store
1516 .load_nodes()?
1517 .into_iter()
1518 .map(|indexed| indexed.node)
1519 .collect::<Vec<_>>();
1520 revalidate_staged_publication_inputs_controlled_with_limits(
1521 plan,
1522 &staged_nodes,
1523 expected_purpose_import_fingerprint,
1524 None,
1525 control,
1526 purpose_limits,
1527 )
1528}
1529
1530fn revalidate_staged_publication_inputs_controlled(
1532 plan: &ScanRuntimePlan,
1533 staged_nodes: &[Node],
1534 expected_purpose_import_fingerprint: Option<&str>,
1535 control: &IndexWorkControl,
1536) -> Result<(), CliError> {
1537 revalidate_staged_publication_inputs_controlled_with_limits(
1538 plan,
1539 staged_nodes,
1540 expected_purpose_import_fingerprint,
1541 None,
1542 control,
1543 PurposeImportLimits::default(),
1544 )
1545}
1546
1547fn revalidate_staged_publication_inputs_with_purpose_snapshot(
1549 plan: &ScanRuntimePlan,
1550 staged_nodes: &[Node],
1551 purpose_import: Option<&PurposeImportSnapshot>,
1552 control: &IndexWorkControl,
1553) -> Result<(), CliError> {
1554 revalidate_staged_publication_inputs_controlled_with_limits(
1555 plan,
1556 staged_nodes,
1557 purpose_import.map(|snapshot| snapshot.fingerprint.as_str()),
1558 purpose_import.map(|snapshot| snapshot.records.as_slice()),
1559 control,
1560 PurposeImportLimits::default(),
1561 )
1562}
1563
1564fn revalidate_staged_publication_inputs_controlled_with_limits(
1566 plan: &ScanRuntimePlan,
1567 staged_nodes: &[Node],
1568 expected_purpose_import_fingerprint: Option<&str>,
1569 reusable_purpose_records: Option<&[atlas_map::ImportedPurposeRecord]>,
1570 control: &IndexWorkControl,
1571 purpose_limits: PurposeImportLimits,
1572) -> Result<(), CliError> {
1573 control.check(IndexWorkStage::Publication)?;
1574 let current_plan = plan
1575 .reload_controlled_with_limits(control, purpose_limits)
1576 .map_err(|source| publication_input_error(&plan.root, source))?;
1577 control.check(IndexWorkStage::Publication)?;
1578 let staged_fingerprint = plan.publication_contract_fingerprint();
1579 let current_fingerprint = current_plan.publication_contract_fingerprint();
1580 if staged_fingerprint != current_fingerprint {
1581 return Err(verification_incomplete(
1582 &plan.root,
1583 IndexVerificationReason::PublicationContractMismatch,
1584 &CliError::InvalidInput(
1585 "derived index policy changed while publication was being built; retry the refresh"
1586 .to_string(),
1587 ),
1588 ));
1589 }
1590 let current_nodes = scan_repo_controlled(
1591 ¤t_plan.root,
1592 ¤t_plan.scan_options,
1593 ScanLimits::default(),
1594 control,
1595 )
1596 .map_err(|source| source_inspection_error(¤t_plan.root, source))?;
1597 verify_source_nodes_match(¤t_plan.root, ¤t_nodes, staged_nodes)?;
1598 if let Some(expected_fingerprint) = expected_purpose_import_fingerprint {
1599 let current_fingerprint = if let Some(records) = reusable_purpose_records {
1600 current_plan
1601 .purpose_import_fingerprint_for_records_controlled_with_limits(
1602 records,
1603 control,
1604 purpose_limits,
1605 )
1606 .map_err(|source| publication_input_error(&plan.root, source))?
1607 } else {
1608 current_plan
1609 .purpose_import_snapshot_controlled_with_limits(
1610 ¤t_nodes,
1611 control,
1612 purpose_limits,
1613 )
1614 .map_err(|source| publication_input_error(&plan.root, source))?
1615 .fingerprint
1616 };
1617 if current_fingerprint != expected_fingerprint {
1618 return Err(verification_incomplete(
1619 &plan.root,
1620 IndexVerificationReason::PublicationContractMismatch,
1621 &CliError::InvalidInput(
1622 "purpose-import inputs changed while publication was being built; retry the refresh"
1623 .to_string(),
1624 ),
1625 ));
1626 }
1627 }
1628 control.check(IndexWorkStage::Publication)?;
1629 Ok(())
1630}
1631
1632fn publication_input_error(root: &Path, source: CliError) -> CliError {
1634 match source {
1635 source @ CliError::IndexWork(_) => source,
1636 other => verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &other),
1637 }
1638}
1639
1640fn source_inspection_error(root: &Path, source: FsError) -> CliError {
1642 match source {
1643 FsError::IndexWork(failure) => failure.into(),
1644 FsError::RepositoryBoundary { .. } => {
1645 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1646 project_root: lossless_project_root_display(root),
1647 worktree: None,
1648 status: IndexReadStatus::VerificationIncomplete,
1649 reason: IndexVerificationReason::PolicyUnavailable,
1650 scope: IndexRefreshScope::Full,
1651 message: source.to_string(),
1652 }))
1653 }
1654 other => CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1655 project_root: lossless_project_root_display(root),
1656 worktree: None,
1657 status: IndexReadStatus::VerificationIncomplete,
1658 reason: IndexVerificationReason::SourceInspectionFailed,
1659 scope: IndexRefreshScope::Full,
1660 message: other.to_string(),
1661 })),
1662 }
1663}
1664
1665fn complete_index_publication(
1667 publication: IndexPublicationGuard<'_>,
1668 control: &IndexWorkControl,
1669) -> Result<(), CliError> {
1670 control.check(IndexWorkStage::Publication)?;
1671 publication.complete()?;
1672 Ok(())
1673}
1674
1675fn hash_index_contract_value(hasher: &mut Hasher, field: &str, value: &str) {
1677 hasher.update(field.as_bytes());
1678 hasher.update(&[0]);
1679 hasher.update(value.as_bytes());
1680 hasher.update(&[0xff]);
1681}
1682
1683fn hash_index_contract_native_path(hasher: &mut Hasher, field: &str, path: &Path) {
1685 hasher.update(field.as_bytes());
1686 hasher.update(&[0]);
1687 hasher.update(path.as_os_str().as_encoded_bytes());
1688 hasher.update(&[0xff]);
1689}
1690
1691fn verification_incomplete(
1693 root: &Path,
1694 reason: IndexVerificationReason,
1695 source: &CliError,
1696) -> CliError {
1697 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1698 project_root: lossless_project_root_display(root),
1699 worktree: None,
1700 status: IndexReadStatus::VerificationIncomplete,
1701 reason,
1702 scope: IndexRefreshScope::Full,
1703 message: source.to_string(),
1704 }))
1705}
1706
1707fn same_indexed_source(current: &Node, indexed: &Node) -> bool {
1709 current.path == indexed.path
1710 && current.kind == indexed.kind
1711 && current.parent_path == indexed.parent_path
1712 && current.extension == indexed.extension
1713 && current.language == indexed.language
1714 && current.size_bytes == indexed.size_bytes
1715 && current.content_hash == indexed.content_hash
1716}
1717
1718fn source_changed_during_derivation(root: &Path, path: &str) -> CliError {
1720 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
1721 project_root: lossless_project_root_display(root),
1722 worktree: None,
1723 status: IndexReadStatus::RefreshRequired,
1724 reason: IndexRefreshReason::SourceChanged,
1725 scope: IndexRefreshScope::Full,
1726 changed: 1,
1727 added: 0,
1728 removed: 0,
1729 modified: 1,
1730 sample_paths: vec![path.to_string()],
1731 }))
1732}
1733
1734impl<'a> PurposeInputReader<'a> {
1735 fn new(
1737 plan: &ScanRuntimePlan,
1738 control: &'a IndexWorkControl,
1739 limits: PurposeImportLimits,
1740 ) -> Self {
1741 let mut complete_paths = BTreeSet::new();
1742 if let Some(path) = &plan.selected_config_path {
1743 complete_paths.insert(path.clone());
1744 }
1745 if let Some(config) = &plan.config {
1746 complete_paths.insert(config.map_path.clone());
1747 complete_paths.insert(config.nonsource_files_path.clone());
1748 }
1749 Self::for_complete_paths(
1750 control,
1751 limits,
1752 complete_paths,
1753 plan.config.as_ref().map_or_else(
1754 || ".purpose".to_string(),
1755 |config| config.purpose_filename().to_string(),
1756 ),
1757 )
1758 }
1759
1760 fn for_complete_paths(
1762 control: &'a IndexWorkControl,
1763 limits: PurposeImportLimits,
1764 complete_paths: BTreeSet<PathBuf>,
1765 purpose_filename: String,
1766 ) -> Self {
1767 Self {
1768 control,
1769 limits,
1770 complete_paths,
1771 purpose_filename,
1772 complete_digests: BTreeMap::new(),
1773 }
1774 }
1775
1776 fn read_text(&mut self, path: &Path) -> Result<String, CliError> {
1778 self.control.check(IndexWorkStage::Publication)?;
1779 let is_complete = self.complete_paths.contains(path)
1780 || path
1781 .file_name()
1782 .is_some_and(|name| name == self.purpose_filename.as_str());
1783 let file_limit = if is_complete {
1784 self.limits.complete_file_bytes
1785 } else {
1786 self.limits.header_bytes
1787 };
1788 let mut file = fs::File::open(path).map_err(|source| CliError::Io {
1789 path: path.to_path_buf(),
1790 source,
1791 })?;
1792 let bytes = self.read_bytes(path, &mut file, file_limit, is_complete)?;
1793 if is_complete {
1794 self.complete_digests.insert(
1795 path.to_path_buf(),
1796 blake3::hash(&bytes).to_hex().to_string(),
1797 );
1798 }
1799 match String::from_utf8(bytes) {
1800 Ok(content) => Ok(content),
1801 Err(source) if !is_complete && source.utf8_error().error_len().is_some() => {
1802 Ok(String::new())
1803 }
1804 Err(source) if !is_complete && source.utf8_error().error_len().is_none() => {
1805 let valid_up_to = source.utf8_error().valid_up_to();
1806 String::from_utf8(source.into_bytes()[..valid_up_to].to_vec()).map_err(|source| {
1807 CliError::InvalidInput(format!(
1808 "purpose header input is not valid UTF-8 for {}: {source}",
1809 normalize_native_path_display(path)
1810 ))
1811 })
1812 }
1813 Err(source) => Err(CliError::InvalidInput(format!(
1814 "purpose input is not valid UTF-8 for {}: {source}",
1815 normalize_native_path_display(path)
1816 ))),
1817 }
1818 }
1819
1820 fn read_bytes<R: Read>(
1822 &mut self,
1823 path: &Path,
1824 reader: &mut R,
1825 file_limit: u64,
1826 require_complete: bool,
1827 ) -> Result<Vec<u8>, CliError> {
1828 let initial_capacity = usize::try_from(file_limit)
1829 .unwrap_or(usize::MAX)
1830 .min(CONTROLLED_SOURCE_READ_BUFFER_BYTES);
1831 let mut bytes = Vec::with_capacity(initial_capacity);
1832 let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
1833 loop {
1834 self.control.check(IndexWorkStage::Publication)?;
1835 let file_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
1836 let remaining = file_limit.saturating_sub(file_bytes);
1837 if remaining == 0 {
1838 if !require_complete {
1839 break;
1840 }
1841 let read = reader
1842 .read(&mut buffer[..1])
1843 .map_err(|source| CliError::Io {
1844 path: path.to_path_buf(),
1845 source,
1846 })?;
1847 if read == 0 {
1848 break;
1849 }
1850 return Err(IndexWorkFailure::resource_limit(
1851 IndexWorkStage::Publication,
1852 IndexWorkResource::PurposeBytes,
1853 file_limit,
1854 file_limit.saturating_add(1),
1855 )
1856 .into());
1857 }
1858 let read_limit = usize::try_from(remaining)
1859 .unwrap_or(usize::MAX)
1860 .min(buffer.len());
1861 let read = reader
1862 .read(&mut buffer[..read_limit])
1863 .map_err(|source| CliError::Io {
1864 path: path.to_path_buf(),
1865 source,
1866 })?;
1867 if read == 0 {
1868 break;
1869 }
1870 self.control.consume_purpose_bytes(
1871 self.limits.total_bytes,
1872 u64::try_from(read).unwrap_or(u64::MAX),
1873 )?;
1874 bytes.extend_from_slice(&buffer[..read]);
1875 }
1876 self.control.check(IndexWorkStage::Publication)?;
1877 Ok(bytes)
1878 }
1879
1880 fn complete_digest(&self, path: &Path) -> Option<&str> {
1882 self.complete_digests.get(path).map(String::as_str)
1883 }
1884}
1885
1886impl ScanRuntimePlan {
1887 pub(crate) fn for_path(
1889 config_path: Option<&Path>,
1890 path: &Path,
1891 text_index_max_bytes: Option<u64>,
1892 ) -> Result<Self, CliError> {
1893 let control = standalone_index_work_control();
1894 Self::for_path_controlled(config_path, path, text_index_max_bytes, &control)
1895 }
1896
1897 pub(crate) fn for_path_controlled(
1899 config_path: Option<&Path>,
1900 path: &Path,
1901 text_index_max_bytes: Option<u64>,
1902 control: &IndexWorkControl,
1903 ) -> Result<Self, CliError> {
1904 Self::for_path_controlled_with_limits(
1905 config_path,
1906 path,
1907 text_index_max_bytes,
1908 control,
1909 PurposeImportLimits::default(),
1910 )
1911 }
1912
1913 fn for_path_controlled_with_limits(
1915 config_path: Option<&Path>,
1916 path: &Path,
1917 text_index_max_bytes: Option<u64>,
1918 control: &IndexWorkControl,
1919 purpose_limits: PurposeImportLimits,
1920 ) -> Result<Self, CliError> {
1921 control.check(IndexWorkStage::Publication)?;
1922 let root = canonical_source_project_root(path)?;
1923 let selected_config_path = selected_scan_import_config_path(config_path, &root)?;
1924 let config = if let Some(path) = selected_config_path.as_deref() {
1925 let mut complete_paths = BTreeSet::new();
1926 complete_paths.insert(path.to_path_buf());
1927 let mut reader = PurposeInputReader::for_complete_paths(
1928 control,
1929 purpose_limits,
1930 complete_paths,
1931 ".purpose".to_string(),
1932 );
1933 let text = reader.read_text(path)?;
1934 let config = load_atlas_config_from_text(path, &text)?;
1935 let config_root = canonical_project_root(&config.root)?;
1936 if config_root != root {
1937 return Err(config_root_mismatch_error(path, &config_root, &root));
1938 }
1939 Some(config)
1940 } else {
1941 None
1942 };
1943 control.check(IndexWorkStage::Publication)?;
1944 let scan_options = config.as_ref().map_or_else(
1945 ScanOptions::default,
1946 atlas_map::AtlasMapConfig::scan_options,
1947 );
1948 let text_options = text_index_options(config.as_ref(), text_index_max_bytes);
1949 #[cfg(feature = "optional-parser-supervisor")]
1950 let optional_parser_selection =
1951 OptionalParserPackLifecycle::new(&root, None)?.derive_project_selection()?;
1952 #[cfg(feature = "optional-parser-supervisor")]
1953 let scan_options = {
1954 let mut scan_options = scan_options;
1955 scan_options.admit_optional_languages =
1956 optional_parser_selection.selection_key().is_some();
1957 scan_options
1958 };
1959 Ok(Self {
1960 root,
1961 config,
1962 selected_config_path,
1963 config_path_override: config_path.map(Path::to_path_buf),
1964 scan_options,
1965 text_options,
1966 text_index_max_bytes_override: text_index_max_bytes,
1967 #[cfg(feature = "optional-parser-supervisor")]
1968 optional_parser_selection,
1969 })
1970 }
1971
1972 fn reload(&self) -> Result<Self, CliError> {
1974 Self::for_path(
1975 self.config_path_override.as_deref(),
1976 &self.root,
1977 self.text_index_max_bytes_override,
1978 )
1979 }
1980
1981 fn reload_controlled(&self, control: &IndexWorkControl) -> Result<Self, CliError> {
1983 self.reload_controlled_with_limits(control, PurposeImportLimits::default())
1984 }
1985
1986 fn reload_controlled_with_limits(
1988 &self,
1989 control: &IndexWorkControl,
1990 purpose_limits: PurposeImportLimits,
1991 ) -> Result<Self, CliError> {
1992 Self::for_path_controlled_with_limits(
1993 self.config_path_override.as_deref(),
1994 &self.root,
1995 self.text_index_max_bytes_override,
1996 control,
1997 purpose_limits,
1998 )
1999 }
2000
2001 fn publication_contract_fingerprint(&self) -> String {
2006 let configured_text_options = text_index_options(self.config.as_ref(), None);
2007 index_derivation_fingerprint(
2008 &self.scan_options,
2009 configured_text_options,
2010 #[cfg(feature = "optional-parser-supervisor")]
2011 &self.optional_parser_selection,
2012 )
2013 }
2014
2015 fn purpose_import_snapshot_controlled(
2017 &self,
2018 nodes: &[Node],
2019 control: &IndexWorkControl,
2020 ) -> Result<PurposeImportSnapshot, CliError> {
2021 self.purpose_import_snapshot_controlled_with_limits(
2022 nodes,
2023 control,
2024 PurposeImportLimits::default(),
2025 )
2026 }
2027
2028 fn purpose_import_snapshot_controlled_with_limits(
2030 &self,
2031 nodes: &[Node],
2032 control: &IndexWorkControl,
2033 limits: PurposeImportLimits,
2034 ) -> Result<PurposeImportSnapshot, CliError> {
2035 control.check(IndexWorkStage::Publication)?;
2036 let mut hasher = Hasher::new();
2037 hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2038 let Some(config) = self.config.as_ref() else {
2039 hash_index_contract_value(&mut hasher, "selected_config", "absent");
2040 return Ok(PurposeImportSnapshot {
2041 records: Vec::new(),
2042 fingerprint: hasher.finalize().to_hex().to_string(),
2043 });
2044 };
2045 let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2046 CliError::InvalidInput(
2047 "purpose import has normalized configuration without a selected config path"
2048 .to_string(),
2049 )
2050 })?;
2051 if u64::try_from(nodes.len()).unwrap_or(u64::MAX) > limits.records {
2052 return Err(IndexWorkFailure::resource_limit(
2053 IndexWorkStage::Publication,
2054 IndexWorkResource::PurposeRecords,
2055 limits.records,
2056 u64::try_from(nodes.len()).unwrap_or(u64::MAX),
2057 )
2058 .into());
2059 }
2060 let mut reader = PurposeInputReader::new(self, control, limits);
2061 hash_publication_input_file_controlled(
2062 &mut hasher,
2063 "selected_config",
2064 selected_config_path,
2065 &mut reader,
2066 )?;
2067 let records = atlas_map::imported_purpose_records_from_nodes(config, nodes, &mut |path| {
2068 reader.read_text(path)
2069 })?;
2070 let fingerprint = finish_purpose_import_fingerprint_controlled(
2071 &mut hasher,
2072 config,
2073 &records,
2074 &mut reader,
2075 control,
2076 limits,
2077 )?;
2078 Ok(PurposeImportSnapshot {
2079 records,
2080 fingerprint,
2081 })
2082 }
2083
2084 fn purpose_import_fingerprint_for_records_controlled_with_limits(
2086 &self,
2087 records: &[atlas_map::ImportedPurposeRecord],
2088 control: &IndexWorkControl,
2089 limits: PurposeImportLimits,
2090 ) -> Result<String, CliError> {
2091 control.check(IndexWorkStage::Publication)?;
2092 let mut hasher = Hasher::new();
2093 hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2094 let Some(config) = self.config.as_ref() else {
2095 hash_index_contract_value(&mut hasher, "selected_config", "absent");
2096 return Ok(hasher.finalize().to_hex().to_string());
2097 };
2098 let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2099 CliError::InvalidInput(
2100 "purpose import has normalized configuration without a selected config path"
2101 .to_string(),
2102 )
2103 })?;
2104 let mut reader = PurposeInputReader::new(self, control, limits);
2105 hash_publication_input_file_controlled(
2106 &mut hasher,
2107 "selected_config",
2108 selected_config_path,
2109 &mut reader,
2110 )?;
2111 finish_purpose_import_fingerprint_controlled(
2112 &mut hasher,
2113 config,
2114 records,
2115 &mut reader,
2116 control,
2117 limits,
2118 )
2119 }
2120}
2121
2122fn finish_purpose_import_fingerprint_controlled(
2124 hasher: &mut Hasher,
2125 config: &atlas_map::AtlasMapConfig,
2126 records: &[atlas_map::ImportedPurposeRecord],
2127 reader: &mut PurposeInputReader<'_>,
2128 control: &IndexWorkControl,
2129 limits: PurposeImportLimits,
2130) -> Result<String, CliError> {
2131 let record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
2132 if record_count > limits.records {
2133 return Err(IndexWorkFailure::resource_limit(
2134 IndexWorkStage::Publication,
2135 IndexWorkResource::PurposeRecords,
2136 limits.records,
2137 record_count,
2138 )
2139 .into());
2140 }
2141 hash_publication_input_file_controlled(hasher, "legacy_map", &config.map_path, reader)?;
2142 hash_publication_input_file_controlled(
2143 hasher,
2144 "nonsource_purposes",
2145 &config.nonsource_files_path,
2146 reader,
2147 )?;
2148 for record in records {
2149 control.check(IndexWorkStage::Publication)?;
2150 hash_index_contract_value(hasher, "purpose_path", &record.path);
2151 hash_index_contract_value(hasher, "purpose_summary", &record.summary);
2152 }
2153 Ok(hasher.finalize().to_hex().to_string())
2154}
2155
2156fn selected_scan_import_config_path(
2158 config_path: Option<&Path>,
2159 root: &Path,
2160) -> Result<Option<PathBuf>, CliError> {
2161 if let Some(config_path) = config_path {
2162 return absolute_path(config_path).map(Some);
2163 }
2164 Ok(config_candidates_for_root(root)
2165 .into_iter()
2166 .find(|candidate| candidate.exists()))
2167}
2168
2169fn hash_publication_input_file_controlled(
2171 hasher: &mut Hasher,
2172 role: &str,
2173 path: &Path,
2174 reader: &mut PurposeInputReader<'_>,
2175) -> Result<(), CliError> {
2176 hash_index_contract_native_path(hasher, role, path);
2177 if !path.exists() {
2178 hash_index_contract_value(hasher, "input_state", "missing");
2179 return Ok(());
2180 }
2181 if reader.complete_digest(path).is_none() {
2182 let _ = reader.read_text(path)?;
2183 }
2184 let digest = reader.complete_digest(path).ok_or_else(|| {
2185 CliError::InvalidInput(format!(
2186 "purpose publication input was not read completely: {}",
2187 normalize_native_path_display(path)
2188 ))
2189 })?;
2190 hash_index_contract_value(hasher, "input_state", "present");
2191 hash_index_contract_value(hasher, "input_digest", digest);
2192 Ok(())
2193}
2194
2195#[derive(Debug, Serialize)]
2197pub(crate) struct ScanReport {
2198 pub(crate) overview: Overview,
2200 pub(crate) purpose_import: PurposeImportReport,
2202 pub(crate) text_index: TextIndexReport,
2204 pub(crate) structural_summaries: StructuralSummaryReport,
2206 pub(crate) symbols: SymbolBuildReport,
2208}
2209
2210#[derive(Debug, Default, Serialize)]
2212pub(crate) struct PurposeImportReport {
2213 pub(crate) imported: usize,
2215 pub(crate) skipped_stale: usize,
2217 pub(crate) skipped_existing: usize,
2219}
2220
2221pub(crate) struct InitBootstrapOptions {
2223 pub(crate) no_scan: bool,
2225 pub(crate) force_rescan: bool,
2227 pub(crate) text_index_max_bytes: Option<u64>,
2229}
2230
2231#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2233#[serde(rename_all = "snake_case")]
2234pub(crate) enum InitPhaseStatus {
2235 Created,
2237 Exists,
2239 Verified,
2241 Skipped,
2243 Failed,
2245}
2246
2247#[derive(Debug, Serialize)]
2249pub(crate) struct InitSetupReport {
2250 pub(crate) ok: bool,
2252 pub(crate) root: Option<String>,
2254 pub(crate) project_dir: InitPathStatus,
2256 pub(crate) config: InitPathStatus,
2258 pub(crate) nonsource_files: InitPathStatus,
2260 pub(crate) db: InitPathStatus,
2262 pub(crate) host_configs: Vec<InitHostConfigStatus>,
2264 pub(crate) scan: InitScanPhase,
2266 #[serde(skip_serializing_if = "Option::is_none")]
2268 pub(crate) hydration: Option<InitHydrationPhase>,
2269 pub(crate) purpose_handoff: PurposeCuratorHandoff,
2271 pub(crate) next_steps: Vec<String>,
2273}
2274
2275#[derive(Debug, Serialize)]
2277pub(crate) struct InitPathStatus {
2278 pub(crate) status: InitPhaseStatus,
2280 pub(crate) path: Option<String>,
2282}
2283
2284#[derive(Debug, Serialize)]
2286pub(crate) struct InitHostConfigStatus {
2287 pub(crate) harness: &'static str,
2289 pub(crate) status: InitPhaseStatus,
2291 pub(crate) path: Option<String>,
2293 #[serde(skip_serializing_if = "Option::is_none")]
2295 pub(crate) error: Option<String>,
2296}
2297
2298#[derive(Debug, Serialize)]
2300pub(crate) struct InitScanPhase {
2301 pub(crate) status: InitPhaseStatus,
2303 pub(crate) requested: bool,
2305 pub(crate) force_rescan: bool,
2307 pub(crate) report: Option<ScanReport>,
2309 #[serde(skip_serializing_if = "Option::is_none")]
2311 pub(crate) error: Option<String>,
2312}
2313
2314#[derive(Debug, Serialize)]
2316pub(crate) struct InitHydrationPhase {
2317 pub(crate) status: InitHydrationStatus,
2319 #[serde(skip_serializing_if = "Option::is_none")]
2321 pub(crate) source_root: Option<String>,
2322 #[serde(skip_serializing_if = "Option::is_none")]
2324 pub(crate) source_project_instance_id: Option<String>,
2325 #[serde(skip_serializing_if = "Option::is_none")]
2327 pub(crate) target_project_instance_id: Option<String>,
2328 #[serde(skip_serializing_if = "Option::is_none")]
2330 pub(crate) baseline_generation: Option<u64>,
2331 #[serde(skip_serializing_if = "Option::is_none")]
2333 pub(crate) reconciled_generation: Option<u64>,
2334 #[serde(skip_serializing_if = "Option::is_none")]
2336 pub(crate) fallback_reason: Option<String>,
2337}
2338
2339#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2341#[serde(rename_all = "snake_case")]
2342pub(crate) enum InitHydrationStatus {
2343 Existing,
2345 Hydrated,
2347 Fallback,
2349}
2350
2351#[derive(Debug, Serialize)]
2353#[allow(
2354 clippy::struct_excessive_bools,
2355 reason = "serialized host policy exposes independent capabilities and guarantees"
2356)]
2357pub(crate) struct PurposeCuratorHandoff {
2358 pub(crate) agent_harness_expected: bool,
2360 pub(crate) execution_owner: &'static str,
2362 pub(crate) recommended_subagent_reasoning: &'static str,
2364 pub(crate) main_agent_fallback: bool,
2366 pub(crate) server_started_curator: bool,
2368 pub(crate) silent_on_success: bool,
2370 pub(crate) queue: PurposeCurationPage,
2372 pub(crate) instructions: Vec<String>,
2374}
2375
2376pub(crate) fn canonical_project_root(root: &Path) -> Result<PathBuf, CliError> {
2378 root.canonicalize().map_err(|source| CliError::Io {
2379 path: root.to_path_buf(),
2380 source,
2381 })
2382}
2383
2384pub(crate) fn lossless_project_root_display(root: &Path) -> Option<String> {
2386 CanonicalProjectRoot::from_path(root)
2387 .ok()
2388 .and_then(|root| root.display_string().ok())
2389}
2390
2391pub(crate) fn lossless_native_path_display(path: &Path) -> Option<String> {
2393 projectatlas_core::lossless_native_path_display(path).ok()
2394}
2395
2396pub(crate) fn canonical_source_project_root(root: &Path) -> Result<PathBuf, CliError> {
2398 match projectatlas_fs::worktree::discover_repository_structure(root)? {
2399 RepositoryStructure::NonGit { selected_root } => Ok(selected_root),
2400 RepositoryStructure::Git(repository) => match repository.selection {
2401 GitRepositorySelection::Worktree { root, .. }
2402 | GitRepositorySelection::CommonManager {
2403 source_selection: GitManagerSourceSelection::Unambiguous { root },
2404 } => Ok(root),
2405 GitRepositorySelection::CommonManager { .. } => Err(CliError::WorktreeRequired(
2406 Box::new(ProjectWorktreeRequired {
2407 project_root: lossless_project_root_display(&repository.common_directory),
2408 status: IndexReadStatus::WorktreeRequired,
2409 }),
2410 )),
2411 },
2412 RepositoryStructure::InvalidGit {
2413 selected_root,
2414 issue,
2415 } => Err(CliError::InvalidInput(format!(
2416 "invalid structural Git evidence for '{}': {:?} at '{}'",
2417 normalize_native_path_display(selected_root),
2418 issue.kind,
2419 normalize_native_path_display(issue.path)
2420 ))),
2421 }
2422}
2423
2424pub(crate) fn index_init_required(root: &Path, database: &Path) -> CliError {
2426 CliError::InitRequired(Box::new(IndexInitRequired {
2427 project_root: lossless_project_root_display(root),
2428 database: lossless_native_path_display(database),
2429 worktree: None,
2430 status: IndexReadStatus::InitRequired,
2431 }))
2432}
2433
2434pub(crate) fn load_scan_import_config(
2436 config_path: Option<&Path>,
2437 scan_path: &Path,
2438) -> Result<Option<atlas_map::AtlasMapConfig>, CliError> {
2439 if let Some(config_path) = config_path {
2440 return Ok(Some(load_atlas_config(Some(config_path))?));
2441 }
2442 let project_config = scan_path.join(".projectatlas").join("config.toml");
2443 if project_config.exists() {
2444 return Ok(Some(load_atlas_config(Some(&project_config))?));
2445 }
2446 let flat_config = scan_path.join("projectatlas.toml");
2447 if flat_config.exists() {
2448 return Ok(Some(load_atlas_config(Some(&flat_config))?));
2449 }
2450 Ok(None)
2451}
2452
2453pub(crate) fn open_atlas_store_for_project(
2455 path: &Path,
2456 root: &Path,
2457) -> Result<AtlasStore, CliError> {
2458 open_atlas_store_for_project_with_location_validator(path, root, validate_database_location)
2459}
2460
2461fn open_atlas_store_for_project_with_location_validator<F>(
2463 path: &Path,
2464 root: &Path,
2465 validate_location: F,
2466) -> Result<AtlasStore, CliError>
2467where
2468 F: FnOnce(&Path) -> projectatlas_db::DbResult<()>,
2469{
2470 validate_location(path).map_err(project_store_error)?;
2471 ensure_parent_dir(path)?;
2472 AtlasStore::open_for_project(path, root).map_err(project_store_error)
2473}
2474
2475pub(crate) fn open_atlas_store_read_only_for_project(
2477 path: &Path,
2478 root: &Path,
2479) -> Result<AtlasStore, CliError> {
2480 AtlasStore::open_read_only_for_project(path, root).map_err(project_store_error)
2481}
2482
2483pub(crate) fn project_store_error(source: projectatlas_db::DbError) -> CliError {
2485 match source {
2486 projectatlas_db::DbError::ProjectRootMismatch {
2487 expected,
2488 found,
2489 identities,
2490 } => CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
2491 status: IndexReadStatus::ProjectMismatch,
2492 worktree: None,
2493 selected_project_root: identities
2494 .as_ref()
2495 .and_then(|identities| identities.expected.display_string().ok()),
2496 indexed_project_root: identities
2497 .as_ref()
2498 .and_then(|identities| identities.found.display_string().ok()),
2499 diagnostic: Some(format!(
2500 "database project root {found:?} does not match selected root {expected:?}"
2501 )),
2502 })),
2503 other => CliError::Db(other),
2504 }
2505}
2506
2507pub(crate) fn init_config_path(root: &Path, explicit: Option<&Path>) -> PathBuf {
2509 if let Some(config_path) = explicit {
2510 return if config_path.is_absolute() {
2511 config_path.to_path_buf()
2512 } else {
2513 root.join(config_path)
2514 };
2515 }
2516 let nested_config = root.join(".projectatlas").join("config.toml");
2517 if nested_config.exists() {
2518 return nested_config;
2519 }
2520 let flat_config = root.join("projectatlas.toml");
2521 if flat_config.exists() {
2522 return flat_config;
2523 }
2524 nested_config
2525}
2526
2527pub(crate) fn run_init_bootstrap(
2529 root: &Path,
2530 db_path: &Path,
2531 config_path: Option<&Path>,
2532 options: &InitBootstrapOptions,
2533) -> Result<InitSetupReport, CliError> {
2534 let root = canonical_source_project_root(root)?;
2535 if db_path.is_file() {
2536 AtlasStore::repair_missing_project_root_identity(db_path, &root)
2537 .map_err(project_store_error)?;
2538 }
2539 preflight_existing_project_binding(db_path, &root)?;
2540 let project_dir = root.join(".projectatlas");
2541 let config_file = init_config_path(&root, config_path);
2542 let nonsource_file = project_dir.join("projectatlas-nonsource-files.toon");
2543 let project_dir_existed = project_dir.exists();
2544 let config_existed = config_file.exists();
2545 let nonsource_existed = nonsource_file.exists();
2546 let db_existed = db_path.exists();
2547
2548 init_project_with_config(&root, Some(&config_file))?;
2549 let mut store = open_atlas_store_for_project(db_path, &root)?;
2550
2551 let mut ok = true;
2552 let (scan_status, scan_report, scan_error) = if options.no_scan {
2553 (InitPhaseStatus::Skipped, None, None)
2554 } else {
2555 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
2556 let control = index_work_control(&symbol_options);
2557 match ScanRuntimePlan::for_path_controlled(
2558 config_path,
2559 &root,
2560 options.text_index_max_bytes,
2561 &control,
2562 )
2563 .and_then(|plan| run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control))
2564 {
2565 Ok(report) => (InitPhaseStatus::Verified, Some(report), None),
2566 Err(error) => {
2567 ok = false;
2568 (InitPhaseStatus::Failed, None, Some(error.to_string()))
2569 }
2570 }
2571 };
2572
2573 let purpose_query = HealthQuery {
2574 start_index: 0,
2575 limit: DEFAULT_HEALTH_LIMIT,
2576 category: None,
2577 severity: None,
2578 path_prefix: None,
2579 summary_only: false,
2580 scope: HealthScope::purpose_default(),
2581 };
2582 let purpose_queue = purpose_curation_page(&store, &purpose_query, "project-init")?;
2583 let next_steps = init_next_steps(options.no_scan, scan_error.is_some(), purpose_queue.total);
2584
2585 Ok(InitSetupReport {
2586 ok,
2587 root: lossless_project_root_display(&root),
2588 project_dir: InitPathStatus {
2589 status: init_path_status(project_dir_existed),
2590 path: lossless_native_path_display(&project_dir),
2591 },
2592 config: InitPathStatus {
2593 status: init_path_status(config_existed),
2594 path: lossless_native_path_display(&config_file),
2595 },
2596 nonsource_files: InitPathStatus {
2597 status: init_path_status(nonsource_existed),
2598 path: lossless_native_path_display(&nonsource_file),
2599 },
2600 db: InitPathStatus {
2601 status: init_path_status(db_existed),
2602 path: lossless_native_path_display(db_path),
2603 },
2604 host_configs: Vec::new(),
2605 scan: InitScanPhase {
2606 status: scan_status,
2607 requested: !options.no_scan,
2608 force_rescan: options.force_rescan,
2609 report: scan_report,
2610 error: scan_error,
2611 },
2612 hydration: None,
2613 purpose_handoff: purpose_curator_handoff(purpose_queue),
2614 next_steps,
2615 })
2616}
2617
2618pub(crate) fn preflight_existing_project_binding(
2625 db_path: &Path,
2626 root: &Path,
2627) -> Result<(), CliError> {
2628 preflight_project_binding_read_only(db_path, root).map_err(project_store_error)
2629}
2630
2631pub(crate) fn init_path_status(existed: bool) -> InitPhaseStatus {
2633 if existed {
2634 InitPhaseStatus::Exists
2635 } else {
2636 InitPhaseStatus::Created
2637 }
2638}
2639
2640fn purpose_handoff_instructions() -> Vec<String> {
2642 vec![
2643 "If the host supports isolated subagents, delegate this actionable low-scope batch at the lowest reliable reasoning and cost tier the host supports; otherwise let the main agent process the same bounded rows without blocking navigation.".to_string(),
2644 "Inspect only bounded current summary, graph, outline, or exact-slice context, then copy task, work_key, and state_token into atlas_purpose_review or projectatlas purpose review --apply; never edit SQLite directly.".to_string(),
2645 "Skip accepted purposes unless an agent or user explicitly assigns a correction; use atlas_purpose_set or projectatlas purpose set for that deliberate correction path.".to_string(),
2646 "Keep successful curator maintenance out of normal conversation; ProjectAtlas reports a handoff and never claims that the Rust server spawned an agent.".to_string(),
2647 ]
2648}
2649
2650pub(crate) fn purpose_curator_handoff(queue: PurposeCurationPage) -> PurposeCuratorHandoff {
2652 PurposeCuratorHandoff {
2653 agent_harness_expected: true,
2654 execution_owner: "agent_host",
2655 recommended_subagent_reasoning: PURPOSE_CURATOR_RECOMMENDED_REASONING,
2656 main_agent_fallback: true,
2657 server_started_curator: false,
2658 silent_on_success: true,
2659 queue,
2660 instructions: purpose_handoff_instructions(),
2661 }
2662}
2663
2664pub(crate) fn init_next_steps(
2666 scan_skipped: bool,
2667 scan_failed: bool,
2668 purpose_queue_total: usize,
2669) -> Vec<String> {
2670 let mut steps = Vec::new();
2671 if scan_skipped {
2672 steps.push("Run projectatlas scan when you are ready to build the deep index.".to_string());
2673 } else if scan_failed {
2674 steps.push(
2675 "Fix the scan/index error and rerun projectatlas init or projectatlas scan."
2676 .to_string(),
2677 );
2678 }
2679 if purpose_queue_total > 0 {
2680 steps.push(
2681 "Use the purpose_handoff queue to delegate purpose creation/correction at the lowest reliable reasoning and cost tier the host supports."
2682 .to_string(),
2683 );
2684 } else {
2685 steps.push("Purpose queue is empty for the default high-impact scope.".to_string());
2686 }
2687 steps.push("Run projectatlas overview to confirm repository orientation.".to_string());
2688 steps
2689}
2690
2691pub(crate) fn ensure_parent_dir(path: &Path) -> Result<(), CliError> {
2693 let Some(parent) = path.parent() else {
2694 return Ok(());
2695 };
2696 if parent.as_os_str().is_empty() {
2697 return Ok(());
2698 }
2699 fs::create_dir_all(parent).map_err(|source| CliError::Io {
2700 path: parent.to_path_buf(),
2701 source,
2702 })
2703}
2704
2705pub(crate) fn config_root_mismatch_error(
2707 config_path: &Path,
2708 config_root: &Path,
2709 selected_root: &Path,
2710) -> CliError {
2711 CliError::InvalidInput(format!(
2712 "ProjectAtlas config '{}' resolves project root '{}' outside selected project root '{}'",
2713 config_path.display(),
2714 config_root.display(),
2715 selected_root.display()
2716 ))
2717}
2718
2719fn legacy_project_root_candidate(db: &Path) -> Result<Option<PathBuf>, CliError> {
2725 if !db.exists() {
2726 return Ok(None);
2727 }
2728 let Some(project_root) = read_legacy_project_root_candidate_read_only(db)? else {
2729 return Ok(None);
2730 };
2731 Ok(Some(canonical_source_project_root(Path::new(
2732 &project_root,
2733 ))?))
2734}
2735
2736pub(crate) fn default_mcp_project_root(
2738 db: &Path,
2739 config_path: Option<&Path>,
2740) -> Result<PathBuf, CliError> {
2741 let legacy_root = legacy_project_root_candidate(db)?;
2742 if let Some(config_path) = config_path {
2743 let config = load_atlas_config(Some(config_path))?;
2744 let config_root = canonical_source_project_root(&config.root)?;
2745 if let Some(db_root) = project_root_from_db_path(db) {
2746 let db_root = canonical_source_project_root(&db_root)?;
2747 if config_root != db_root {
2748 return Err(config_root_mismatch_error(
2749 config_path,
2750 &config_root,
2751 &db_root,
2752 ));
2753 }
2754 }
2755 return Ok(config_root);
2756 }
2757 if db.exists()
2758 && let Some(project_root) = read_project_root_identity_read_only(db)?
2759 {
2760 return canonical_source_project_root(project_root.as_path());
2761 }
2762 if let Some(project_root) = legacy_root {
2763 return Ok(project_root);
2764 }
2765 if let Some(project_root) = project_root_from_db_path(db) {
2766 return canonical_source_project_root(&project_root);
2767 }
2768 let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
2769 path: PathBuf::from("."),
2770 source,
2771 })?;
2772 canonical_source_project_root(¤t_dir)
2773}
2774
2775pub(crate) fn default_cli_project_root(
2777 db: &Path,
2778 config_path: Option<&Path>,
2779 database_path_is_explicit: bool,
2780) -> Result<PathBuf, CliError> {
2781 if !database_path_is_explicit
2782 && config_path.is_none()
2783 && !db.exists()
2784 && let Some(project_root) = project_root_from_db_path(db)
2785 {
2786 return canonical_source_project_root(&project_root);
2787 }
2788 default_mcp_project_root(db, config_path)
2789}
2790
2791pub(crate) fn defaultable_cli_project_root(
2793 path: &Path,
2794 db: &Path,
2795 config_path: Option<&Path>,
2796 database_path_is_explicit: bool,
2797) -> Result<PathBuf, CliError> {
2798 if path == Path::new(".") {
2799 return default_cli_project_root(db, config_path, database_path_is_explicit);
2800 }
2801 Ok(path.to_path_buf())
2802}
2803
2804fn project_root_from_db_path(db: &Path) -> Option<PathBuf> {
2806 let parent = db.parent()?;
2807 let cache_dir_name = parent.file_name()?;
2808 if cache_dir_name != ".projectatlas" {
2809 return None;
2810 }
2811 parent
2812 .parent()
2813 .filter(|root| !root.as_os_str().is_empty())
2814 .map_or_else(|| Some(PathBuf::from(".")), |root| Some(root.to_path_buf()))
2815}
2816
2817pub(crate) fn scan_options_for_root(
2819 config_path: Option<&Path>,
2820 root: &Path,
2821) -> Result<ScanOptions, CliError> {
2822 Ok(load_scan_import_config(config_path, root)?
2823 .as_ref()
2824 .map_or_else(
2825 ScanOptions::default,
2826 atlas_map::AtlasMapConfig::scan_options,
2827 ))
2828}
2829
2830pub(crate) fn text_index_options(
2832 config: Option<&atlas_map::AtlasMapConfig>,
2833 max_bytes_override: Option<u64>,
2834) -> TextIndexOptions {
2835 let max_bytes = max_bytes_override
2836 .filter(|value| *value > 0)
2837 .or_else(|| config.map(atlas_map::AtlasMapConfig::text_index_max_bytes))
2838 .unwrap_or(atlas_map::DEFAULT_TEXT_INDEX_MAX_BYTES);
2839 TextIndexOptions::new(max_bytes)
2840}
2841
2842fn publication_base_generation(store: &AtlasStore) -> Result<IndexGeneration, CliError> {
2844 Ok(store
2845 .index_publication()?
2846 .map_or(IndexGeneration::ZERO, |publication| publication.generation))
2847}
2848
2849fn stage_full_index_publication(
2851 store: &AtlasStore,
2852 plan: &ScanRuntimePlan,
2853 symbol_options: &SymbolBuildOptions,
2854 reuse_unchanged_symbols: bool,
2855 import_legacy_purposes: bool,
2856 control: &IndexWorkControl,
2857) -> Result<IndexPublicationBatch, CliError> {
2858 let base_generation = publication_base_generation(store)?;
2859 let contract_fingerprint = plan.publication_contract_fingerprint();
2860 let previous_hashes = reuse_unchanged_symbols
2861 .then(|| indexed_file_hashes(store))
2862 .transpose()?;
2863 let nodes = scan_repo_controlled(
2864 &plan.root,
2865 &plan.scan_options,
2866 ScanLimits::default(),
2867 control,
2868 )
2869 .map_err(|source| source_inspection_error(&plan.root, source))?;
2870 control.check(IndexWorkStage::Publication)?;
2871 let purpose_import = import_legacy_purposes
2872 .then(|| plan.purpose_import_snapshot_controlled(&nodes, control))
2873 .transpose()?;
2874 let protected_purpose_paths = protected_purpose_paths(&nodes, purpose_import.as_ref());
2875 let text_paths = nodes
2876 .iter()
2877 .filter(|node| node.kind == NodeKind::File)
2878 .map(|node| node.path.clone())
2879 .collect::<Vec<_>>();
2880 let text = stage_text_index_for_changed_paths_controlled(
2881 &plan.root,
2882 &nodes,
2883 plan.text_options,
2884 control,
2885 )?;
2886 let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
2887 let retained_before_symbols =
2888 staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
2889 .saturating_add(staged_string_bytes(&text_paths))
2890 .saturating_add(staged_node_bytes(&nodes))
2891 .saturating_add(staged_text_bytes(&text))
2892 .saturating_add(staged_file_content_classification_bytes(
2893 &content_classifications,
2894 ))
2895 .saturating_add(staged_purpose_bytes(purpose_import.as_ref()));
2896 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
2897 let symbols = stage_symbols_for_nodes_with_limits(
2898 store,
2899 &plan.root,
2900 #[cfg(feature = "optional-parser-supervisor")]
2901 &plan.optional_parser_selection,
2902 &nodes,
2903 symbol_options,
2904 previous_hashes.as_ref(),
2905 None,
2906 &protected_purpose_paths,
2907 control,
2908 symbol_limits,
2909 )?;
2910 let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
2911 .map_err(|source| source_inspection_error(&plan.root, source))?;
2912 let graph = graph_projection::stage_full_repository_graph(
2913 store,
2914 &plan.root,
2915 base_generation,
2916 &nodes,
2917 &scan_policy,
2918 &symbols,
2919 control,
2920 )?;
2921 let structural_summaries = stage_structural_summaries_for_nodes_controlled(
2922 store,
2923 &nodes,
2924 &text.rows,
2925 Some(&symbols),
2926 &protected_purpose_paths,
2927 symbol_options.effective_workers(),
2928 control,
2929 )?;
2930 enforce_publication_staging_budget(
2931 retained_before_symbols
2932 .saturating_add(symbols.retained_bytes)
2933 .saturating_add(graph.retained_bytes())
2934 .saturating_add(structural_summaries.retained_bytes),
2935 )?;
2936 Ok(IndexPublicationBatch {
2937 base_generation,
2938 contract_fingerprint,
2939 root: plan.root.clone(),
2940 nodes: NodePublicationBatch::Full { nodes },
2941 purpose_import,
2942 text_paths,
2943 text,
2944 content_classifications,
2945 symbols,
2946 graph,
2947 structural_summaries,
2948 })
2949}
2950
2951fn protected_purpose_paths(
2953 nodes: &[Node],
2954 purpose_import: Option<&PurposeImportSnapshot>,
2955) -> HashSet<String> {
2956 let indexed_paths = nodes
2957 .iter()
2958 .map(|node| node.path.as_str())
2959 .collect::<HashSet<_>>();
2960 let mut protected = BUILTIN_PROJECTATLAS_PURPOSES
2961 .iter()
2962 .filter(|(path, _purpose)| indexed_paths.contains(*path))
2963 .map(|(path, _purpose)| (*path).to_string())
2964 .collect::<HashSet<_>>();
2965 if let Some(snapshot) = purpose_import {
2966 protected.extend(
2967 snapshot
2968 .records
2969 .iter()
2970 .filter(|record| indexed_paths.contains(record.path.as_str()))
2971 .map(|record| record.path.clone()),
2972 );
2973 }
2974 protected
2975}
2976
2977fn staged_node_bytes(nodes: &[Node]) -> u64 {
2979 nodes.iter().fold(0_u64, |bytes, node| {
2980 bytes
2981 .saturating_add(node.path.len() as u64)
2982 .saturating_add(
2983 node.parent_path
2984 .as_ref()
2985 .map_or(0, |value| value.len() as u64),
2986 )
2987 .saturating_add(
2988 node.extension
2989 .as_ref()
2990 .map_or(0, |value| value.len() as u64),
2991 )
2992 .saturating_add(node.language.as_ref().map_or(0, |value| value.len() as u64))
2993 .saturating_add(
2994 node.content_hash
2995 .as_ref()
2996 .map_or(0, |value| value.len() as u64),
2997 )
2998 })
2999}
3000
3001fn staged_text_bytes(text: &TextIndexRefresh) -> u64 {
3003 text.rows.iter().fold(0_u64, |bytes, row| {
3004 let bytes = bytes.saturating_add(row.path.len() as u64);
3005 row.text.as_ref().map_or(bytes, |text| {
3006 bytes
3007 .saturating_add(text.path.len() as u64)
3008 .saturating_add(
3009 text.content_hash
3010 .as_ref()
3011 .map_or(0, |value| value.len() as u64),
3012 )
3013 .saturating_add(text.content.len() as u64)
3014 })
3015 })
3016}
3017
3018fn stage_file_content_classifications(
3020 nodes: &[Node],
3021 text_rows: &[TextIndexRow],
3022) -> Vec<FileContentClassification> {
3023 let languages = nodes
3024 .iter()
3025 .filter(|node| node.kind == NodeKind::File)
3026 .map(|node| (node.path.as_str(), node.language.as_deref()))
3027 .collect::<HashMap<_, _>>();
3028 text_rows
3029 .iter()
3030 .map(|row| FileContentClassification {
3031 path: row.path.clone(),
3032 classification: content_classification(
3033 languages.get(row.path.as_str()).copied().flatten(),
3034 row.reason == TextIndexSkipReason::Indexed,
3035 ),
3036 })
3037 .collect()
3038}
3039
3040fn staged_file_content_classification_bytes(rows: &[FileContentClassification]) -> u64 {
3042 rows.iter().fold(0_u64, |bytes, row| {
3043 bytes
3044 .saturating_add(row.path.len() as u64)
3045 .saturating_add(row.classification.as_str().len() as u64)
3046 })
3047}
3048
3049fn staged_purpose_bytes(purpose_import: Option<&PurposeImportSnapshot>) -> u64 {
3051 purpose_import.map_or(0, |snapshot| {
3052 snapshot
3053 .records
3054 .iter()
3055 .fold(snapshot.fingerprint.len() as u64, |bytes, record| {
3056 bytes
3057 .saturating_add(record.path.len() as u64)
3058 .saturating_add(record.summary.len() as u64)
3059 })
3060 })
3061}
3062
3063fn staged_string_bytes(values: &[String]) -> u64 {
3065 values.iter().fold(0_u64, |bytes, value| {
3066 bytes.saturating_add(value.len() as u64)
3067 })
3068}
3069
3070fn staged_publication_identity_bytes(root: &Path, contract_fingerprint: &str) -> u64 {
3072 (root.as_os_str().as_encoded_bytes().len() as u64)
3073 .saturating_add(contract_fingerprint.len() as u64)
3074}
3075
3076fn symbol_limits_with_remaining_staging_bytes(
3078 retained_bytes: u64,
3079) -> Result<SymbolPublicationLimits, CliError> {
3080 enforce_publication_staging_budget(retained_bytes)?;
3081 Ok(SymbolPublicationLimits {
3082 output_bytes: SymbolPublicationLimits::STANDARD
3083 .output_bytes
3084 .min(MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_bytes)),
3085 ..SymbolPublicationLimits::STANDARD
3086 })
3087}
3088
3089fn enforce_publication_staging_budget(retained_bytes: u64) -> Result<(), CliError> {
3091 if retained_bytes > MAX_PUBLICATION_STAGING_BYTES {
3092 return Err(IndexWorkFailure::resource_limit(
3093 IndexWorkStage::Publication,
3094 IndexWorkResource::OutputBytes,
3095 MAX_PUBLICATION_STAGING_BYTES,
3096 retained_bytes,
3097 )
3098 .into());
3099 }
3100 Ok(())
3101}
3102
3103fn expected_nodes_after_incremental(
3105 baseline_nodes: Vec<Node>,
3106 changed_nodes: &[Node],
3107 absent_paths: &[String],
3108) -> Vec<Node> {
3109 let absent_paths = absent_paths
3110 .iter()
3111 .map(String::as_str)
3112 .filter(|path| !matches!(*path, "" | "."))
3113 .collect::<HashSet<_>>();
3114 let mut expected = baseline_nodes
3115 .into_iter()
3116 .filter(|node| !repository_path_is_absent(&node.path, &absent_paths))
3117 .map(|node| (node.path.clone(), node))
3118 .collect::<BTreeMap<_, _>>();
3119 for node in changed_nodes {
3120 expected.insert(node.path.clone(), node.clone());
3121 }
3122 expected.into_values().collect()
3123}
3124
3125fn repository_path_is_absent(path: &str, absent_paths: &HashSet<&str>) -> bool {
3127 absent_paths.contains(path)
3128 || path
3129 .match_indices('/')
3130 .any(|(separator, _)| absent_paths.contains(&path[..separator]))
3131}
3132
3133fn publish_index_batch(
3135 store: &mut AtlasStore,
3136 batch: IndexPublicationBatch,
3137 control: &IndexWorkControl,
3138) -> Result<IndexPublicationOutcome, CliError> {
3139 control.check(IndexWorkStage::Publication)?;
3140 let IndexPublicationBatch {
3141 base_generation,
3142 contract_fingerprint,
3143 root,
3144 nodes,
3145 purpose_import,
3146 text_paths,
3147 text,
3148 content_classifications,
3149 mut symbols,
3150 graph,
3151 structural_summaries,
3152 } = batch;
3153 graph.revalidate_document_targets(&root)?;
3154 let mut publication =
3155 store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
3156 publication.set_project_root(&root)?;
3157 let indexed_nodes = match nodes {
3158 NodePublicationBatch::Full { nodes } => {
3159 publication.begin_scan_replacement()?;
3160 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3161 control.check(IndexWorkStage::Publication)?;
3162 publication.upsert_scan_node_batch(batch)?;
3163 }
3164 apply_file_content_classification_stage(
3165 &mut publication,
3166 &content_classifications,
3167 control,
3168 )?;
3169 control.check(IndexWorkStage::Publication)?;
3170 publication.finish_scan_replacement()?;
3171 nodes
3172 }
3173 NodePublicationBatch::Incremental {
3174 nodes,
3175 absent_paths,
3176 expected_nodes: _,
3177 } => {
3178 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3179 control.check(IndexWorkStage::Publication)?;
3180 publication.upsert_scan_node_batch(batch)?;
3181 }
3182 for batch in absent_paths.chunks(PUBLICATION_PATH_BATCH_SIZE) {
3183 control.check(IndexWorkStage::Publication)?;
3184 publication.mark_paths_absent(batch)?;
3185 }
3186 apply_file_content_classification_stage(
3187 &mut publication,
3188 &content_classifications,
3189 control,
3190 )?;
3191 nodes
3192 }
3193 };
3194 seed_builtin_projectatlas_purposes(&publication, &indexed_nodes)?;
3195 apply_text_index_stage(&mut publication, &text_paths, &text, control)?;
3196 let purpose_import = purpose_import.map_or_else(
3197 || Ok(PurposeImportReport::default()),
3198 |snapshot| apply_purpose_import_snapshot(&publication, &indexed_nodes, &snapshot, control),
3199 )?;
3200 apply_symbol_build_stage(&mut publication, &mut symbols, control)?;
3201 graph.apply(&mut publication, control)?;
3202 apply_structural_summary_stage(&mut publication, &structural_summaries, control)?;
3203 complete_index_publication(publication, control)?;
3204 Ok(IndexPublicationOutcome {
3205 purpose_import,
3206 text_index: text.report,
3207 structural_summaries: structural_summaries.report,
3208 symbols: symbols.report,
3209 })
3210}
3211
3212fn apply_file_content_classification_stage(
3214 publication: &mut IndexPublicationGuard<'_>,
3215 rows: &[FileContentClassification],
3216 control: &IndexWorkControl,
3217) -> Result<(), CliError> {
3218 for batch in rows.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
3219 control.check(IndexWorkStage::Publication)?;
3220 publication.upsert_file_content_classification_batch(batch)?;
3221 }
3222 Ok(())
3223}
3224
3225fn apply_purpose_import_snapshot(
3227 store: &AtlasStore,
3228 nodes: &[Node],
3229 snapshot: &PurposeImportSnapshot,
3230 control: &IndexWorkControl,
3231) -> Result<PurposeImportReport, CliError> {
3232 let indexed_paths = nodes
3233 .iter()
3234 .map(|node| node.path.as_str())
3235 .collect::<HashSet<_>>();
3236 let mut report = PurposeImportReport::default();
3237 for record in &snapshot.records {
3238 control.check(IndexWorkStage::Publication)?;
3239 if !indexed_paths.contains(record.path.as_str()) {
3240 report.skipped_stale += 1;
3241 continue;
3242 }
3243 let Some(indexed) = store.load_node_by_path(&record.path)? else {
3244 report.skipped_stale += 1;
3245 continue;
3246 };
3247 if matches!(
3248 indexed.purpose.status,
3249 PurposeStatus::Approved | PurposeStatus::Stale
3250 ) {
3251 report.skipped_existing += 1;
3252 continue;
3253 }
3254 store.set_purpose(&record.path, &record.summary, PurposeSource::Imported)?;
3255 report.imported += 1;
3256 }
3257 Ok(report)
3258}
3259
3260#[cfg(test)]
3262pub(crate) fn run_scan_pipeline(
3263 store: &mut AtlasStore,
3264 plan: &ScanRuntimePlan,
3265 symbol_options: &SymbolBuildOptions,
3266) -> Result<ScanReport, CliError> {
3267 let control = index_work_control(symbol_options);
3268 run_scan_pipeline_controlled(store, plan, symbol_options, &control)
3269}
3270
3271pub(crate) fn run_scan_pipeline_controlled(
3273 store: &mut AtlasStore,
3274 plan: &ScanRuntimePlan,
3275 symbol_options: &SymbolBuildOptions,
3276 control: &IndexWorkControl,
3277) -> Result<ScanReport, CliError> {
3278 let bounded_control = bounded_index_work_control(control);
3279 let control = &bounded_control;
3280 control.check(IndexWorkStage::Publication)?;
3281 store.probe_index_publication_writer()?;
3282 let batch = stage_full_index_publication(store, plan, symbol_options, false, true, control)?;
3283 revalidate_staged_publication_inputs_with_purpose_snapshot(
3284 plan,
3285 batch.nodes.expected_nodes(),
3286 batch.purpose_import.as_ref(),
3287 control,
3288 )?;
3289 let outcome = publish_index_batch(store, batch, control)?;
3290 let overview = store.overview()?;
3291 Ok(ScanReport {
3292 overview,
3293 purpose_import: outcome.purpose_import,
3294 text_index: outcome.text_index,
3295 structural_summaries: outcome.structural_summaries,
3296 symbols: outcome.symbols,
3297 })
3298}
3299
3300pub(crate) fn reconcile_hydrated_index_controlled(
3302 store: &mut AtlasStore,
3303 plan: &ScanRuntimePlan,
3304 symbol_options: &SymbolBuildOptions,
3305 control: &IndexWorkControl,
3306) -> Result<(ScanReport, bool), CliError> {
3307 if !publication_contract_matches(store, plan)? {
3308 return run_scan_pipeline_controlled(store, plan, symbol_options, control)
3309 .map(|report| (report, false));
3310 }
3311 let assessment =
3312 detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?;
3313 let (refresh, source_unchanged) = match assessment.delta {
3314 None => (empty_index_refresh_report(plan.text_options), true),
3315 Some(delta) if delta.report.scope == IndexRefreshScope::Incremental => {
3316 let changes = WatchChangeSet {
3317 requires_full_scan: false,
3318 document_paths: delta.paths.clone(),
3319 paths: delta.paths,
3320 };
3321 let refresh = match refresh_index_for_changes_controlled(
3322 store,
3323 plan,
3324 &changes,
3325 symbol_options,
3326 control,
3327 ) {
3328 Ok(refresh) => refresh,
3329 Err(CliError::RefreshRequired(report))
3330 if report.reason == IndexRefreshReason::DependencyClosureLimit =>
3331 {
3332 refresh_index_controlled(store, plan, symbol_options, control)?
3333 }
3334 Err(error) => return Err(error),
3335 };
3336 (refresh, false)
3337 }
3338 Some(_delta) => (
3339 refresh_index_controlled(store, plan, symbol_options, control)?,
3340 false,
3341 ),
3342 };
3343 Ok((
3344 ScanReport {
3345 overview: store.overview()?,
3346 purpose_import: PurposeImportReport::default(),
3347 text_index: refresh.text_index,
3348 structural_summaries: refresh.structural_summaries,
3349 symbols: refresh.symbols,
3350 },
3351 source_unchanged,
3352 ))
3353}
3354
3355#[cfg(test)]
3357pub(crate) fn run_symbol_build_pipeline(
3358 store: &mut AtlasStore,
3359 plan: &ScanRuntimePlan,
3360 symbol_options: &SymbolBuildOptions,
3361 previous_hashes: Option<&HashMap<String, String>>,
3362) -> Result<SymbolBuildReport, CliError> {
3363 let control = index_work_control(symbol_options);
3364 run_symbol_build_pipeline_controlled(store, plan, symbol_options, previous_hashes, &control)
3365}
3366
3367pub(crate) fn run_symbol_build_pipeline_controlled(
3369 store: &mut AtlasStore,
3370 plan: &ScanRuntimePlan,
3371 symbol_options: &SymbolBuildOptions,
3372 previous_hashes: Option<&HashMap<String, String>>,
3373 control: &IndexWorkControl,
3374) -> Result<SymbolBuildReport, CliError> {
3375 let bounded_control = bounded_index_work_control(control);
3376 let control = &bounded_control;
3377 control.check(IndexWorkStage::SymbolParsing)?;
3378 verify_index_project_root(store, &plan.root)?;
3379 verify_index_publication(store, plan)?;
3380 control.check(IndexWorkStage::Publication)?;
3381 store.probe_index_publication_writer()?;
3382 let base_generation = publication_base_generation(store)?;
3383 let nodes = store
3384 .load_nodes()?
3385 .into_iter()
3386 .map(|indexed| indexed.node)
3387 .collect::<Vec<_>>();
3388 let contract_fingerprint = plan.publication_contract_fingerprint();
3389 let retained_before_symbols =
3390 staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
3391 .saturating_add(staged_node_bytes(&nodes));
3392 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
3393 let mut staged = stage_symbols_for_nodes_with_limits(
3394 store,
3395 &plan.root,
3396 #[cfg(feature = "optional-parser-supervisor")]
3397 &plan.optional_parser_selection,
3398 &nodes,
3399 symbol_options,
3400 previous_hashes,
3401 None,
3402 &HashSet::new(),
3403 control,
3404 symbol_limits,
3405 )?;
3406 let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
3407 .map_err(|source| source_inspection_error(&plan.root, source))?;
3408 let graph = graph_projection::stage_full_repository_graph(
3409 store,
3410 &plan.root,
3411 base_generation,
3412 &nodes,
3413 &scan_policy,
3414 &staged,
3415 control,
3416 )?;
3417 enforce_publication_staging_budget(
3418 retained_before_symbols
3419 .saturating_add(staged.retained_bytes)
3420 .saturating_add(graph.retained_bytes()),
3421 )?;
3422 revalidate_staged_publication_inputs_controlled(plan, &nodes, None, control)?;
3423 control.check(IndexWorkStage::Publication)?;
3424 let mut publication =
3425 store.begin_index_projection_refresh_from(&contract_fingerprint, base_generation)?;
3426 apply_symbol_build_stage(&mut publication, &mut staged, control)?;
3427 graph.apply(&mut publication, control)?;
3428 complete_index_publication(publication, control)?;
3429 Ok(staged.report)
3430}
3431
3432#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3434pub(crate) struct UsageRuntimeInstance {
3435 id: UsageInstanceId,
3437 owner: UsageInstanceOwner,
3439}
3440
3441impl UsageRuntimeInstance {
3442 #[must_use]
3444 pub(crate) fn new(owner: UsageInstanceOwner) -> Option<Self> {
3445 let mut bytes = [0u8; 16];
3446 getrandom::fill(&mut bytes).ok()?;
3447 UsageInstanceId::from_bytes(bytes)
3448 .ok()
3449 .map(|id| Self { id, owner })
3450 }
3451
3452 fn record(
3454 self,
3455 store: &AtlasStore,
3456 event: &projectatlas_core::telemetry::UsageEvent,
3457 ) -> Result<(), CliError> {
3458 store.record_usage_for_instance(
3459 self.id,
3460 self.owner,
3461 event,
3462 matches!(self.owner, UsageInstanceOwner::CliInvocation),
3463 )?;
3464 Ok(())
3465 }
3466
3467 pub(crate) fn record_for_worktree(
3469 self,
3470 store: &AtlasStore,
3471 registration_id: i64,
3472 event: &projectatlas_core::telemetry::UsageEvent,
3473 ) -> Result<(), CliError> {
3474 store.record_usage_for_worktree_instance(
3475 self.id,
3476 self.owner,
3477 registration_id,
3478 event,
3479 false,
3480 )?;
3481 Ok(())
3482 }
3483
3484 pub(crate) fn seal(self, store: &AtlasStore) -> Result<(), CliError> {
3486 store.seal_usage_instance(self.id)?;
3487 Ok(())
3488 }
3489}
3490
3491pub(crate) fn record_usage_estimate(
3493 store: &AtlasStore,
3494 usage_instance: Option<UsageRuntimeInstance>,
3495 session: &str,
3496 command: &str,
3497 path: Option<String>,
3498 query: Option<String>,
3499 estimated_without_projectatlas: usize,
3500 projectatlas_text: &str,
3501) -> Result<(), CliError> {
3502 record_usage_estimate_with_context(
3503 store,
3504 usage_instance,
3505 session,
3506 command,
3507 path,
3508 query,
3509 estimated_without_projectatlas,
3510 projectatlas_text,
3511 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3512 TOKEN_BASELINE_SELECTED_CANDIDATES,
3513 TOKEN_CONFIDENCE_INFERRED,
3514 )
3515}
3516
3517#[allow(clippy::too_many_arguments)]
3519pub(crate) fn record_usage_estimate_with_context(
3520 store: &AtlasStore,
3521 usage_instance: Option<UsageRuntimeInstance>,
3522 session: &str,
3523 command: &str,
3524 path: Option<String>,
3525 query: Option<String>,
3526 estimated_without_projectatlas: usize,
3527 projectatlas_text: &str,
3528 token_savings_bucket: &str,
3529 baseline_kind: &str,
3530 confidence: &str,
3531) -> Result<(), CliError> {
3532 let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3533 return Ok(());
3534 };
3535 store.finish_index_read_snapshot()?;
3536 usage_instance.record(
3537 store,
3538 &usage_from_estimates_with_context(
3539 session,
3540 command,
3541 path,
3542 query,
3543 estimated_without_projectatlas,
3544 estimate_tokens(projectatlas_text),
3545 token_savings_bucket,
3546 baseline_kind,
3547 confidence,
3548 ),
3549 )?;
3550 Ok(())
3551}
3552
3553pub(crate) fn record_directory_walk_usage_estimate(
3555 store: &AtlasStore,
3556 usage_instance: Option<UsageRuntimeInstance>,
3557 session: &str,
3558 command: &str,
3559 path: Option<String>,
3560 query: Option<String>,
3561 estimated_without_projectatlas: usize,
3562 projectatlas_text: &str,
3563) -> Result<(), CliError> {
3564 record_usage_estimate_with_context(
3565 store,
3566 usage_instance,
3567 session,
3568 command,
3569 path,
3570 query,
3571 estimated_without_projectatlas,
3572 projectatlas_text,
3573 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3574 TOKEN_BASELINE_DIRECTORY_WALK,
3575 TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3576 )
3577}
3578
3579pub(crate) fn record_usage_text(
3581 store: &AtlasStore,
3582 usage_instance: Option<UsageRuntimeInstance>,
3583 session: &str,
3584 command: &str,
3585 path: Option<String>,
3586 query: Option<String>,
3587 baseline_text: &str,
3588 projectatlas_text: &str,
3589) -> Result<(), CliError> {
3590 let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3591 return Ok(());
3592 };
3593 store.finish_index_read_snapshot()?;
3594 usage_instance.record(
3595 store,
3596 &usage_from_text(
3597 session,
3598 command,
3599 path,
3600 query,
3601 baseline_text,
3602 projectatlas_text,
3603 ),
3604 )?;
3605 Ok(())
3606}
3607
3608pub(crate) fn telemetry_disabled() -> bool {
3610 truthy_env("PROJECTATLAS_NO_TELEMETRY")
3611}
3612
3613pub(crate) fn estimated_source_tokens_for_indexed_files(
3615 store: &AtlasStore,
3616 folder: Option<&str>,
3617 file_pattern: Option<&str>,
3618) -> Result<usize, CliError> {
3619 let matcher = FilePathMatcher::new(file_pattern)?;
3620 let mut total = 0usize;
3621 store.visit_file_token_estimates(folder, |path, size_bytes| {
3622 if matcher.is_match(&path) {
3623 total =
3624 total.saturating_add(estimated_source_tokens_for_file_metadata(&path, size_bytes));
3625 }
3626 Ok(true)
3627 })?;
3628 Ok(total)
3629}
3630
3631pub(crate) fn estimated_source_tokens_for_file_node(node: &Node) -> usize {
3633 estimated_source_tokens_for_file_metadata(&node.path, node.size_bytes)
3634}
3635
3636pub(crate) fn estimated_source_tokens_for_file_metadata(
3638 path: &str,
3639 size_bytes: Option<u64>,
3640) -> usize {
3641 size_bytes.map_or_else(|| estimate_tokens(path), byte_size_to_tokens)
3642}
3643
3644pub(crate) fn byte_size_to_tokens(bytes: u64) -> usize {
3646 let token_estimate = bytes.div_ceil(4);
3647 usize::try_from(token_estimate).unwrap_or(usize::MAX)
3648}
3649
3650pub(crate) fn byte_count_to_tokens(bytes: usize) -> usize {
3652 if bytes == 0 { 0 } else { bytes.div_ceil(4) }
3653}
3654
3655pub(crate) fn ranked_folder_nodes_with_reasons(
3657 store: &AtlasStore,
3658 query: &str,
3659 limit: usize,
3660) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3661 Ok(load_ranked_folder_nodes_with_reasons(store, query, limit)?)
3662}
3663
3664pub(crate) fn ranked_file_nodes_with_reasons(
3666 store: &AtlasStore,
3667 query: &str,
3668 folder: Option<&str>,
3669 file_pattern: Option<&str>,
3670 limit: usize,
3671 include_content: bool,
3672) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3673 Ok(load_ranked_file_nodes_with_reasons(
3674 store,
3675 query,
3676 folder,
3677 file_pattern,
3678 limit,
3679 include_content,
3680 )?)
3681}
3682
3683pub(crate) fn classified_ranked_file_nodes_with_reasons(
3685 store: &AtlasStore,
3686 query: &str,
3687 folder: Option<&str>,
3688 file_pattern: Option<&str>,
3689 limit: usize,
3690 include_content: bool,
3691 content_selection: ContentSelection,
3692) -> Result<Vec<ClassifiedRankedNode>, CliError> {
3693 Ok(load_classified_ranked_file_nodes_with_reasons_service(
3694 store,
3695 query,
3696 folder,
3697 file_pattern,
3698 limit,
3699 include_content,
3700 content_selection,
3701 )?)
3702}
3703
3704pub(crate) fn next_step_report_with_selection(
3706 store: &AtlasStore,
3707 query: &str,
3708 limit: Option<usize>,
3709 content_selection: ContentSelection,
3710) -> Result<NextStepReport, CliError> {
3711 Ok(build_next_report_with_selection_service(
3712 store,
3713 query,
3714 limit,
3715 content_selection,
3716 )?)
3717}
3718
3719pub(crate) fn next_step_report_payload(report: &NextStepReport) -> Value {
3721 json!({
3722 "query": &report.query,
3723 "folders": render_ranked_node_rows("folders", &report.folders),
3724 "files": render_classified_ranked_file_rows(&report.files),
3725 "suggestions": &report.suggestions,
3726 })
3727}
3728
3729pub(crate) fn render_classified_ranked_file_rows(files: &[ClassifiedRankedNode]) -> Vec<Value> {
3731 files
3732 .iter()
3733 .map(|file| {
3734 let mut row = render_ranked_node_rows("files", std::slice::from_ref(&file.ranked))
3735 .into_iter()
3736 .next()
3737 .unwrap_or_else(|| json!({}));
3738 if let Some(object) = row.as_object_mut() {
3739 object.insert("classification".to_string(), json!(file.classification));
3740 }
3741 row
3742 })
3743 .collect()
3744}
3745
3746pub(crate) fn render_classified_symbol_rows(symbols: &[ClassifiedSymbol]) -> Vec<Value> {
3748 symbols
3749 .iter()
3750 .map(|classified| {
3751 let mut row = render_symbol_rows(std::slice::from_ref(&classified.symbol))
3752 .into_iter()
3753 .next()
3754 .unwrap_or_else(|| json!({}));
3755 if let Some(object) = row.as_object_mut() {
3756 object.insert(
3757 "classification".to_string(),
3758 json!(classified.classification),
3759 );
3760 }
3761 row
3762 })
3763 .collect()
3764}
3765
3766#[derive(Debug, Serialize)]
3768#[allow(
3769 clippy::struct_excessive_bools,
3770 reason = "serialized queue paging and scope fields are independent wire facts"
3771)]
3772pub(crate) struct PurposeCurationPage {
3773 pub(crate) project_instance_id: String,
3775 pub(crate) active_generation: u64,
3777 pub(crate) task: String,
3779 pub(crate) work_key: String,
3781 pub(crate) actionable: bool,
3783 pub(crate) curation_scope: &'static str,
3785 pub(crate) total: usize,
3787 pub(crate) unfiltered_total: usize,
3789 pub(crate) returned: usize,
3791 pub(crate) start_index: usize,
3793 pub(crate) limit: usize,
3795 pub(crate) max_limit: usize,
3797 pub(crate) next_start_index: Option<usize>,
3799 pub(crate) truncated: bool,
3801 pub(crate) source_only: bool,
3803 pub(crate) folder_scope: String,
3805 pub(crate) file_scope: String,
3807 pub(crate) category: String,
3809 pub(crate) severity: String,
3811 pub(crate) path_prefix: String,
3813 pub(crate) summary_only: bool,
3815 pub(crate) items: Vec<PurposeCurationItem>,
3817}
3818
3819#[derive(Debug, Serialize)]
3821pub(crate) struct PurposeCurationItem {
3822 pub(crate) work_key: String,
3824 pub(crate) state_token: String,
3826 pub(crate) severity: String,
3828 pub(crate) id: String,
3830 pub(crate) category: String,
3832 pub(crate) path: String,
3834 pub(crate) related_path: String,
3836 pub(crate) kind: String,
3838 pub(crate) language: String,
3840 pub(crate) classification: Option<ContentClassification>,
3842 pub(crate) purpose: String,
3844 pub(crate) purpose_status: String,
3846 pub(crate) purpose_source: String,
3848 pub(crate) purpose_agent_reviewed: bool,
3850 pub(crate) review_priority: String,
3852 pub(crate) review_reason: String,
3854 pub(crate) content_summary: String,
3856 pub(crate) recommendation: String,
3858}
3859
3860#[derive(Clone, Debug, Deserialize, Serialize)]
3862pub(crate) struct PurposeReviewRequest {
3863 pub(crate) path: String,
3865 #[serde(default)]
3867 pub(crate) purpose: Option<String>,
3868 #[serde(default)]
3870 pub(crate) confirm_existing: bool,
3871 #[serde(default)]
3873 pub(crate) task: Option<String>,
3874 #[serde(default)]
3876 pub(crate) work_key: Option<String>,
3877 #[serde(default)]
3879 pub(crate) state_token: Option<String>,
3880}
3881
3882#[derive(Debug, Serialize)]
3884pub(crate) struct PurposeReviewReport {
3885 pub(crate) applied: bool,
3887 pub(crate) total: usize,
3889 pub(crate) changed: usize,
3891 pub(crate) skipped: usize,
3893 pub(crate) conflicts: usize,
3895 pub(crate) failed: usize,
3897 pub(crate) items: Vec<PurposeReviewItem>,
3899}
3900
3901#[derive(Debug, Serialize)]
3903pub(crate) struct PurposeReviewItem {
3904 pub(crate) path: String,
3906 pub(crate) classification: Option<ContentClassification>,
3908 pub(crate) action: PurposeReviewAction,
3910 pub(crate) current_status: String,
3912 pub(crate) current_source: String,
3914 pub(crate) purpose: String,
3916 pub(crate) error: String,
3918}
3919
3920#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
3922#[serde(rename_all = "kebab-case")]
3923pub(crate) enum PurposeReviewAction {
3924 Error,
3926 Skip,
3928 Review,
3930 WouldReview,
3932 Stale,
3934 Accepted,
3936 Unavailable,
3938}
3939
3940pub(crate) fn review_purposes(
3942 store: &AtlasStore,
3943 requests: &[PurposeReviewRequest],
3944 apply: bool,
3945) -> Result<PurposeReviewReport, CliError> {
3946 validate_purpose_review_admission(requests)?;
3947 let has_conditional_fields = requests.iter().any(has_conditional_purpose_review_field);
3948 if apply
3949 && has_conditional_fields
3950 && !requests.iter().all(is_complete_conditional_purpose_review)
3951 {
3952 return Err(CliError::InvalidInput(
3953 "an applied purpose review batch must be entirely conditional or entirely explicit correction; conditional rows require task, work_key, state_token, and a reviewed purpose"
3954 .to_string(),
3955 ));
3956 }
3957
3958 if apply && !has_conditional_fields {
3962 let mut preview = collect_purpose_reviews(store, requests, false)?;
3963 hydrate_purpose_review_classifications(store, &mut preview)?;
3964 validate_purpose_review_report(&preview)?;
3965 }
3966
3967 let mut report = if apply && has_conditional_fields {
3968 apply_conditional_purpose_reviews(store, requests)?
3969 } else {
3970 collect_purpose_reviews(store, requests, apply)?
3971 };
3972 hydrate_purpose_review_classifications(store, &mut report)?;
3973 validate_purpose_review_report(&report)?;
3974 Ok(report)
3975}
3976
3977fn hydrate_purpose_review_classifications(
3979 store: &AtlasStore,
3980 report: &mut PurposeReviewReport,
3981) -> Result<(), CliError> {
3982 let paths = report
3983 .items
3984 .iter()
3985 .map(|item| item.path.clone())
3986 .collect::<Vec<_>>();
3987 let file_paths = store
3988 .load_nodes_by_paths(&paths)?
3989 .into_iter()
3990 .filter(|node| node.node.kind == NodeKind::File)
3991 .map(|node| node.node.path)
3992 .collect::<Vec<_>>();
3993 let classifications = store
3994 .file_content_classifications_for_paths(&file_paths)?
3995 .into_iter()
3996 .map(|row| (row.path, row.classification))
3997 .collect::<HashMap<_, _>>();
3998 for item in &mut report.items {
3999 item.classification = classifications.get(&item.path).copied();
4000 }
4001 Ok(())
4002}
4003
4004pub(crate) fn validate_purpose_review_admission(
4006 requests: &[PurposeReviewRequest],
4007) -> Result<(), CliError> {
4008 if requests.is_empty() {
4009 return Err(CliError::InvalidInput(
4010 "purpose review input must contain at least one item".to_string(),
4011 ));
4012 }
4013 if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4014 return Err(CliError::InvalidInput(format!(
4015 "purpose review input contains {} items; maximum is {}",
4016 requests.len(),
4017 MAX_PURPOSE_CURATION_BATCH_ROWS
4018 )));
4019 }
4020
4021 let mut aggregate_bytes = 0usize;
4022 for (index, request) in requests.iter().enumerate() {
4023 validate_purpose_review_field(index, "path", &request.path, MAX_PURPOSE_REVIEW_PATH_BYTES)?;
4024 aggregate_bytes = aggregate_bytes
4025 .checked_add(request.path.len())
4026 .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4027 for (name, value) in [
4028 ("purpose", request.purpose.as_deref()),
4029 ("task", request.task.as_deref()),
4030 ("work_key", request.work_key.as_deref()),
4031 ("state_token", request.state_token.as_deref()),
4032 ] {
4033 if let Some(value) = value {
4034 validate_purpose_review_field(index, name, value, MAX_PURPOSE_REVIEW_FIELD_BYTES)?;
4035 aggregate_bytes = aggregate_bytes
4036 .checked_add(value.len())
4037 .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4038 }
4039 }
4040 if aggregate_bytes > MAX_PURPOSE_REVIEW_INPUT_BYTES {
4041 return Err(purpose_review_input_too_large(aggregate_bytes));
4042 }
4043 }
4044 Ok(())
4045}
4046
4047fn validate_purpose_review_field(
4049 index: usize,
4050 name: &str,
4051 value: &str,
4052 maximum: usize,
4053) -> Result<(), CliError> {
4054 if value.len() > maximum {
4055 return Err(CliError::InvalidInput(format!(
4056 "purpose review item {index} field {name} contains {} bytes; maximum is {maximum}",
4057 value.len()
4058 )));
4059 }
4060 Ok(())
4061}
4062
4063fn purpose_review_input_too_large(actual: usize) -> CliError {
4065 CliError::InvalidInput(format!(
4066 "purpose review input contains {actual} aggregate string bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_BYTES}"
4067 ))
4068}
4069
4070fn collect_purpose_reviews(
4072 store: &AtlasStore,
4073 requests: &[PurposeReviewRequest],
4074 apply: bool,
4075) -> Result<PurposeReviewReport, CliError> {
4076 let mut items = Vec::with_capacity(requests.len());
4077 let mut retained_bytes = 0usize;
4078 for request in requests {
4079 let item = review_purpose_request(store, request, apply)?;
4080 retained_bytes = retained_bytes
4081 .checked_add(purpose_review_item_bytes(&item)?)
4082 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4083 if retained_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4084 return Err(purpose_review_report_too_large(retained_bytes));
4085 }
4086 items.push(item);
4087 }
4088 Ok(summarize_purpose_review(requests.len(), apply, items))
4089}
4090
4091fn purpose_review_item_bytes(item: &PurposeReviewItem) -> Result<usize, CliError> {
4093 let mut total = 0usize;
4094 for (name, value, maximum) in [
4095 ("path", item.path.as_str(), MAX_PURPOSE_REVIEW_PATH_BYTES),
4096 (
4097 "current_status",
4098 item.current_status.as_str(),
4099 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4100 ),
4101 (
4102 "current_source",
4103 item.current_source.as_str(),
4104 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4105 ),
4106 (
4107 "purpose",
4108 item.purpose.as_str(),
4109 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4110 ),
4111 (
4112 PURPOSE_REVIEW_REPORT_ERROR_FIELD,
4113 item.error.as_str(),
4114 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4115 ),
4116 ] {
4117 if value.len() > maximum {
4118 return Err(CliError::InvalidInput(format!(
4119 "purpose review report field {name} contains {} bytes; maximum is {maximum}",
4120 value.len()
4121 )));
4122 }
4123 total = total
4124 .checked_add(value.len())
4125 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4126 }
4127 total = total
4128 .checked_add(item.classification.map_or(0, |value| value.as_str().len()))
4129 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4130 Ok(total)
4131}
4132
4133fn validate_purpose_review_report(report: &PurposeReviewReport) -> Result<(), CliError> {
4135 let json_bytes = serde_json::to_string_pretty(report)?
4136 .len()
4137 .checked_add(1)
4138 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4139 if json_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4140 return Err(purpose_review_report_too_large(json_bytes));
4141 }
4142 let toon_bytes = render_purpose_review_report(report).len();
4143 if toon_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4144 return Err(purpose_review_report_too_large(toon_bytes));
4145 }
4146 Ok(())
4147}
4148
4149fn purpose_review_report_too_large(actual: usize) -> CliError {
4151 CliError::InvalidInput(format!(
4152 "purpose review report contains {actual} bytes; maximum is {MAX_PURPOSE_REVIEW_REPORT_BYTES}"
4153 ))
4154}
4155
4156fn apply_conditional_purpose_reviews(
4158 store: &AtlasStore,
4159 requests: &[PurposeReviewRequest],
4160) -> Result<PurposeReviewReport, CliError> {
4161 let prepared = requests
4162 .iter()
4163 .map(|request| {
4164 let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4165 CliError::InvalidInput(format!(
4166 "invalid purpose review path {:?}: {source}",
4167 request.path
4168 ))
4169 })?;
4170 let purpose = request
4171 .purpose
4172 .as_deref()
4173 .map(str::trim)
4174 .filter(|value| !value.is_empty())
4175 .ok_or_else(|| {
4176 CliError::InvalidInput(
4177 "conditional purpose review requires an explicit reviewed purpose"
4178 .to_string(),
4179 )
4180 })?
4181 .to_string();
4182 let task = request.task.clone().ok_or_else(|| {
4183 CliError::InvalidInput(
4184 "conditional purpose review requires task, work_key, and state_token together"
4185 .to_string(),
4186 )
4187 })?;
4188 let work_key = request.work_key.clone().ok_or_else(|| {
4189 CliError::InvalidInput(
4190 "conditional purpose review requires task, work_key, and state_token together"
4191 .to_string(),
4192 )
4193 })?;
4194 let state_token = request.state_token.clone().ok_or_else(|| {
4195 CliError::InvalidInput(
4196 "conditional purpose review requires task, work_key, and state_token together"
4197 .to_string(),
4198 )
4199 })?;
4200 Ok((
4201 path.clone(),
4202 purpose.clone(),
4203 PurposeConditionalApplyRequest {
4204 task,
4205 path,
4206 work_key,
4207 state_token,
4208 purpose,
4209 },
4210 ))
4211 })
4212 .collect::<Result<Vec<_>, CliError>>()?;
4213 let database_requests = prepared
4214 .iter()
4215 .map(|(_, _, request)| request.clone())
4216 .collect::<Vec<_>>();
4217 let results = store.conditionally_set_purposes(&database_requests)?;
4218 let items = prepared
4219 .into_iter()
4220 .zip(results)
4221 .map(|((path, purpose, _), result)| {
4222 debug_assert_eq!(path, result.path);
4223 PurposeReviewItem {
4224 path,
4225 classification: None,
4226 action: conditional_purpose_review_action(result.state, true),
4227 current_status: result
4228 .current_purpose
4229 .as_ref()
4230 .map(|purpose| purpose.status.to_string())
4231 .unwrap_or_default(),
4232 current_source: result
4233 .current_purpose
4234 .as_ref()
4235 .map(|purpose| purpose.source.to_string())
4236 .unwrap_or_default(),
4237 purpose,
4238 error: String::new(),
4239 }
4240 })
4241 .collect::<Vec<_>>();
4242 Ok(summarize_purpose_review(requests.len(), true, items))
4243}
4244
4245fn is_complete_conditional_purpose_review(request: &PurposeReviewRequest) -> bool {
4247 request.task.is_some()
4248 && request.work_key.is_some()
4249 && request.state_token.is_some()
4250 && request
4251 .purpose
4252 .as_deref()
4253 .is_some_and(|purpose| !purpose.trim().is_empty())
4254}
4255
4256fn has_conditional_purpose_review_field(request: &PurposeReviewRequest) -> bool {
4258 request.task.is_some() || request.work_key.is_some() || request.state_token.is_some()
4259}
4260
4261fn summarize_purpose_review(
4263 total: usize,
4264 applied: bool,
4265 items: Vec<PurposeReviewItem>,
4266) -> PurposeReviewReport {
4267 let changed = items
4268 .iter()
4269 .filter(|item| {
4270 matches!(
4271 item.action,
4272 PurposeReviewAction::Review | PurposeReviewAction::WouldReview
4273 )
4274 })
4275 .count();
4276 let skipped = items
4277 .iter()
4278 .filter(|item| {
4279 matches!(
4280 item.action,
4281 PurposeReviewAction::Skip | PurposeReviewAction::Accepted
4282 )
4283 })
4284 .count();
4285 let conflicts = items
4286 .iter()
4287 .filter(|item| {
4288 matches!(
4289 item.action,
4290 PurposeReviewAction::Stale
4291 | PurposeReviewAction::Accepted
4292 | PurposeReviewAction::Unavailable
4293 )
4294 })
4295 .count();
4296 let failed = items.iter().filter(|item| !item.error.is_empty()).count();
4297 PurposeReviewReport {
4298 applied,
4299 total,
4300 changed,
4301 skipped,
4302 conflicts,
4303 failed,
4304 items,
4305 }
4306}
4307
4308fn review_purpose_request(
4310 store: &AtlasStore,
4311 request: &PurposeReviewRequest,
4312 apply: bool,
4313) -> Result<PurposeReviewItem, CliError> {
4314 let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4315 CliError::InvalidInput(format!(
4316 "invalid purpose review path {:?}: {source}",
4317 request.path
4318 ))
4319 })?;
4320 let conditional = match (
4321 request.task.as_deref(),
4322 request.work_key.as_deref(),
4323 request.state_token.as_deref(),
4324 ) {
4325 (Some(task), Some(work_key), Some(state_token)) => Some((task, work_key, state_token)),
4326 (None, None, None) => None,
4327 _ => {
4328 return Ok(PurposeReviewItem {
4329 path,
4330 classification: None,
4331 action: PurposeReviewAction::Error,
4332 current_status: String::new(),
4333 current_source: String::new(),
4334 purpose: request.purpose.clone().unwrap_or_default(),
4335 error:
4336 "conditional purpose review requires task, work_key, and state_token together"
4337 .to_string(),
4338 });
4339 }
4340 };
4341 if let Some((task, work_key, state_token)) = conditional {
4342 let reviewed_purpose = request
4343 .purpose
4344 .as_deref()
4345 .map(str::trim)
4346 .filter(|value| !value.is_empty());
4347 let Some(reviewed_purpose) = reviewed_purpose else {
4348 return Ok(PurposeReviewItem {
4349 path,
4350 classification: None,
4351 action: PurposeReviewAction::Error,
4352 current_status: String::new(),
4353 current_source: String::new(),
4354 purpose: String::new(),
4355 error: "conditional purpose review requires an explicit reviewed purpose"
4356 .to_string(),
4357 });
4358 };
4359 let state = if apply {
4360 store.conditionally_set_purpose(task, &path, work_key, state_token, reviewed_purpose)?
4361 } else {
4362 preview_conditional_purpose_review(store, task, &path, work_key, state_token)?
4363 };
4364 let current = store.load_node_by_path(&path)?;
4365 return Ok(PurposeReviewItem {
4366 path,
4367 classification: None,
4368 action: conditional_purpose_review_action(state, apply),
4369 current_status: current
4370 .as_ref()
4371 .map(|node| node.purpose.status.to_string())
4372 .unwrap_or_default(),
4373 current_source: current
4374 .as_ref()
4375 .map(|node| node.purpose.source.to_string())
4376 .unwrap_or_default(),
4377 purpose: reviewed_purpose.to_string(),
4378 error: String::new(),
4379 });
4380 }
4381 let Some(indexed) = store.load_node_by_path(&path)? else {
4382 return Ok(PurposeReviewItem {
4383 path,
4384 classification: None,
4385 action: PurposeReviewAction::Error,
4386 current_status: String::new(),
4387 current_source: String::new(),
4388 purpose: request.purpose.clone().unwrap_or_default(),
4389 error: "path is not indexed".to_string(),
4390 });
4391 };
4392 let current_status = indexed.purpose.status.to_string();
4393 let current_source = indexed.purpose.source.to_string();
4394 let current_purpose = indexed.purpose.purpose.clone().unwrap_or_default();
4395 let explicit_purpose = request
4396 .purpose
4397 .as_deref()
4398 .map(str::trim)
4399 .filter(|value| !value.is_empty());
4400 let Some(reviewed_purpose) = explicit_purpose.or_else(|| {
4401 request
4402 .confirm_existing
4403 .then_some(current_purpose.as_str())
4404 .filter(|value| !value.trim().is_empty())
4405 }) else {
4406 return Ok(PurposeReviewItem {
4407 path,
4408 classification: None,
4409 action: PurposeReviewAction::Error,
4410 current_status,
4411 current_source,
4412 purpose: String::new(),
4413 error: "provide a reviewed purpose or set confirm_existing=true".to_string(),
4414 });
4415 };
4416
4417 if request.confirm_existing
4418 && explicit_purpose.is_none()
4419 && (indexed.purpose.status == PurposeStatus::Suggested
4420 || indexed.purpose.source == PurposeSource::Generated)
4421 {
4422 return Ok(PurposeReviewItem {
4423 path,
4424 classification: None,
4425 action: PurposeReviewAction::Error,
4426 current_status,
4427 current_source,
4428 purpose: current_purpose,
4429 error: "generated suggestions require an explicit reviewed purpose".to_string(),
4430 });
4431 }
4432
4433 let reviewed_purpose = reviewed_purpose.trim().to_string();
4434 let action = if indexed.purpose.agent_reviewed() && current_purpose == reviewed_purpose {
4435 PurposeReviewAction::Skip
4436 } else if apply {
4437 store.set_purpose(&path, &reviewed_purpose, PurposeSource::Agent)?;
4438 PurposeReviewAction::Review
4439 } else {
4440 PurposeReviewAction::WouldReview
4441 };
4442 Ok(PurposeReviewItem {
4443 path,
4444 classification: None,
4445 action,
4446 current_status,
4447 current_source,
4448 purpose: reviewed_purpose,
4449 error: String::new(),
4450 })
4451}
4452
4453fn preview_conditional_purpose_review(
4455 store: &AtlasStore,
4456 task: &str,
4457 path: &str,
4458 work_key: &str,
4459 state_token: &str,
4460) -> Result<PurposeConditionalApplyState, CliError> {
4461 let batch = store.load_purpose_curation_batch(task, &[path.to_string()])?;
4462 if let Some(candidate) = batch.items.first() {
4463 return Ok(
4464 if candidate.work_key == work_key && candidate.state_token == state_token {
4465 PurposeConditionalApplyState::Applied
4466 } else {
4467 PurposeConditionalApplyState::Stale
4468 },
4469 );
4470 }
4471 Ok(store
4472 .load_node_by_path(path)?
4473 .map_or(PurposeConditionalApplyState::PathUnavailable, |_node| {
4474 PurposeConditionalApplyState::Accepted
4475 }))
4476}
4477
4478const fn conditional_purpose_review_action(
4480 state: PurposeConditionalApplyState,
4481 apply: bool,
4482) -> PurposeReviewAction {
4483 match state {
4484 PurposeConditionalApplyState::Applied if apply => PurposeReviewAction::Review,
4485 PurposeConditionalApplyState::Applied => PurposeReviewAction::WouldReview,
4486 PurposeConditionalApplyState::Stale => PurposeReviewAction::Stale,
4487 PurposeConditionalApplyState::Accepted => PurposeReviewAction::Accepted,
4488 PurposeConditionalApplyState::PathUnavailable => PurposeReviewAction::Unavailable,
4489 }
4490}
4491
4492pub(crate) fn purpose_curation_page(
4494 store: &AtlasStore,
4495 query: &HealthQuery,
4496 task: &str,
4497) -> Result<PurposeCurationPage, CliError> {
4498 let page = store.purpose_curation_findings_page_current(query)?;
4499 let paths = page
4500 .findings
4501 .iter()
4502 .map(|finding| finding.path.clone())
4503 .collect::<Vec<_>>();
4504 let batch = store.load_purpose_curation_batch(task, &paths)?;
4505 let file_paths = batch
4506 .items
4507 .iter()
4508 .filter(|candidate| candidate.node.node.kind == NodeKind::File)
4509 .map(|candidate| candidate.node.node.path.clone())
4510 .collect::<Vec<_>>();
4511 let classifications = store
4512 .file_content_classifications_for_paths(&file_paths)?
4513 .into_iter()
4514 .map(|row| (row.path, row.classification))
4515 .collect::<HashMap<_, _>>();
4516 let project_instance_id = batch.project_instance_id.to_string();
4517 let active_generation = batch.active_generation.get();
4518 let task = batch.task.clone();
4519 let work_key = batch.work_key.clone();
4520 let candidates = batch
4521 .items
4522 .into_iter()
4523 .map(|candidate| (candidate.node.node.path.clone(), candidate))
4524 .collect::<HashMap<_, _>>();
4525 let items = page
4526 .findings
4527 .iter()
4528 .filter_map(|finding| {
4529 let candidate = candidates.get(&finding.path)?;
4530 let node = &candidate.node;
4531 let review_signal = purpose_review_signal(&node.node, &node.purpose);
4532 Some(PurposeCurationItem {
4533 work_key: candidate.work_key.clone(),
4534 state_token: candidate.state_token.clone(),
4535 severity: health_severity_name(finding.severity).to_string(),
4536 id: finding.id.clone(),
4537 category: finding.category.clone(),
4538 path: finding.path.clone(),
4539 related_path: finding.related_path.clone().unwrap_or_default(),
4540 kind: node.node.kind.to_string(),
4541 language: node.node.language.clone().unwrap_or_default(),
4542 classification: classifications.get(&finding.path).copied(),
4543 purpose: node.purpose.purpose.clone().unwrap_or_default(),
4544 purpose_status: node.purpose.status.to_string(),
4545 purpose_source: node.purpose.source.to_string(),
4546 purpose_agent_reviewed: node.purpose.agent_reviewed(),
4547 review_priority: review_signal.priority.to_string(),
4548 review_reason: review_signal.reason.to_string(),
4549 content_summary: node.summary.clone().unwrap_or_default(),
4550 recommendation: "Inspect bounded context, then use conditional purpose review with this task, work_key, and state_token."
4551 .to_string(),
4552 })
4553 })
4554 .collect::<Vec<_>>();
4555 let returned = items.len();
4556 Ok(PurposeCurationPage {
4557 project_instance_id,
4558 active_generation,
4559 task,
4560 work_key,
4561 actionable: returned > 0,
4562 curation_scope: purpose_queue_curation_scope(query),
4563 total: page.total,
4564 unfiltered_total: page.unfiltered_total,
4565 returned,
4566 start_index: page.start_index,
4567 limit: page.limit,
4568 max_limit: MAX_HEALTH_LIMIT,
4569 next_start_index: health_next_start_index(&page),
4570 truncated: health_next_start_index(&page).is_some(),
4571 source_only: query.scope.is_source_focused(),
4572 folder_scope: purpose_queue_folder_scope(query).to_string(),
4573 file_scope: purpose_queue_file_scope(query).to_string(),
4574 category: query.category.clone().unwrap_or_default(),
4575 severity: query.severity.map_or("", health_severity_name).to_string(),
4576 path_prefix: query.path_prefix.clone().unwrap_or_default(),
4577 summary_only: query.summary_only,
4578 items,
4579 })
4580}
4581
4582pub(crate) fn render_health_page(page: &HealthFindingsPage, query: &HealthQuery) -> String {
4584 let rows = page
4585 .findings
4586 .iter()
4587 .map(|finding| {
4588 json!({
4589 "severity": health_severity_name(finding.severity),
4590 "id": finding.id,
4591 "category": finding.category,
4592 "path": finding.path,
4593 "related_path": finding.related_path.as_deref().unwrap_or(""),
4594 "message": finding.message,
4595 "recommendation": finding.recommendation,
4596 })
4597 })
4598 .collect::<Vec<_>>();
4599 encode_agent_payload(&json!({
4600 "health": {
4601 "total": page.total,
4602 "unfiltered_total": page.unfiltered_total,
4603 "returned": page.returned,
4604 "start_index": page.start_index,
4605 "limit": page.limit,
4606 "max_limit": MAX_HEALTH_LIMIT,
4607 "next_start_index": health_next_start_index(page),
4608 "truncated": health_next_start_index(page).is_some(),
4609 "summary_only": query.summary_only,
4610 "source_only": query.scope.is_source_focused(),
4611 "category": query.category.as_deref().unwrap_or(""),
4612 "severity": query.severity.map_or("", health_severity_name),
4613 "path_prefix": query.path_prefix.as_deref().unwrap_or(""),
4614 },
4615 "health_findings": rows,
4616 }))
4617}
4618
4619pub(crate) fn render_coverage_report(report: &CoverageDiscoveryReport) -> String {
4621 encode_agent_payload(&json!({ "coverage": report }))
4622}
4623
4624pub(crate) fn render_purpose_curation_page(page: &PurposeCurationPage) -> String {
4626 encode_agent_payload(&json!({
4627 "purpose_curation": {
4628 "project_instance_id": page.project_instance_id,
4629 "active_generation": page.active_generation,
4630 "task": page.task,
4631 "work_key": page.work_key,
4632 "actionable": page.actionable,
4633 "curation_scope": page.curation_scope,
4634 "total": page.total,
4635 "unfiltered_total": page.unfiltered_total,
4636 "returned": page.returned,
4637 "start_index": page.start_index,
4638 "limit": page.limit,
4639 "max_limit": page.max_limit,
4640 "next_start_index": page.next_start_index,
4641 "truncated": page.truncated,
4642 "source_only": page.source_only,
4643 "folder_scope": page.folder_scope,
4644 "file_scope": page.file_scope,
4645 "category": page.category,
4646 "severity": page.severity,
4647 "path_prefix": page.path_prefix,
4648 "summary_only": page.summary_only,
4649 },
4650 "purpose_curation_items": page.items,
4651 }))
4652}
4653
4654pub(crate) fn render_purpose_review_report(report: &PurposeReviewReport) -> String {
4656 encode_agent_payload(&json!({
4657 "purpose_review": {
4658 "applied": report.applied,
4659 "total": report.total,
4660 "changed": report.changed,
4661 "skipped": report.skipped,
4662 "conflicts": report.conflicts,
4663 "failed": report.failed,
4664 },
4665 "purpose_review_items": report.items,
4666 }))
4667}
4668
4669fn purpose_queue_folder_scope(query: &HealthQuery) -> &'static str {
4671 match query.scope {
4672 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "source_relevant",
4673 _ => "all",
4674 }
4675}
4676
4677fn purpose_queue_file_scope(query: &HealthQuery) -> &'static str {
4679 match query.scope {
4680 HealthScope::PurposeDefault => "high_impact",
4681 HealthScope::PurposeWithAssets => "high_impact_and_assets",
4682 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "all_source",
4683 HealthScope::All | HealthScope::PurposeStrict => "all",
4684 }
4685}
4686
4687fn purpose_queue_curation_scope(query: &HealthQuery) -> &'static str {
4689 match query.scope {
4690 HealthScope::PurposeDefault => "low",
4691 HealthScope::PurposeWithAssets => "low_with_assets",
4692 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "medium",
4693 HealthScope::All | HealthScope::PurposeStrict => "strict",
4694 }
4695}
4696
4697pub(crate) fn health_severity_name(severity: Severity) -> &'static str {
4699 severity.as_str()
4700}
4701
4702fn health_next_start_index(page: &HealthFindingsPage) -> Option<usize> {
4704 let page_width = page.limit.min(page.total.saturating_sub(page.start_index));
4705 let page_end = page.start_index.saturating_add(page_width);
4706 if page_width == 0 || page_end >= page.total {
4707 None
4708 } else {
4709 Some(page_end)
4710 }
4711}
4712
4713pub(crate) fn estimated_source_tokens_for_paths<'a>(
4715 store: &AtlasStore,
4716 paths: impl Iterator<Item = &'a str>,
4717) -> Result<usize, CliError> {
4718 let mut seen = HashSet::new();
4719 let mut total = 0usize;
4720 for path in paths {
4721 if seen.insert(path.to_string()) {
4722 total = total.saturating_add(estimated_source_tokens_for_path(store, path)?);
4723 }
4724 }
4725 Ok(total)
4726}
4727
4728pub(crate) fn estimated_source_tokens_for_path(
4730 store: &AtlasStore,
4731 path: &str,
4732) -> Result<usize, CliError> {
4733 if let Some(indexed) = store.load_node_by_path(path)?
4734 && indexed.node.kind == NodeKind::File
4735 {
4736 return Ok(estimated_source_tokens_for_file_node(&indexed.node));
4737 }
4738 Ok(read_indexed_file_content(store, path).map_or_else(
4739 |_| estimate_tokens(path),
4740 |content| estimate_tokens(&content),
4741 ))
4742}
4743
4744#[derive(Clone, Debug, Serialize)]
4746pub(crate) struct TextIndexReport {
4747 pub(crate) candidates: usize,
4749 pub(crate) indexed: usize,
4751 pub(crate) binary_or_non_utf8: usize,
4753 pub(crate) too_large: usize,
4755 pub(crate) skipped: usize,
4757 pub(crate) max_bytes: u64,
4759 pub(crate) bytes: usize,
4761}
4762
4763#[derive(Clone, Debug, Default, Serialize)]
4765pub(crate) struct StructuralSummaryReport {
4766 pub(crate) candidates: usize,
4768 pub(crate) summarized: usize,
4770 pub(crate) cleared: usize,
4772 pub(crate) too_large: usize,
4774 pub(crate) binary_or_non_utf8: usize,
4776 pub(crate) purpose_suggestions: usize,
4778}
4779
4780#[derive(Clone, Copy, Debug)]
4782pub(crate) struct TextIndexOptions {
4783 pub(crate) max_bytes: u64,
4785}
4786
4787impl TextIndexOptions {
4788 pub(crate) fn new(max_bytes: u64) -> Self {
4790 Self { max_bytes }
4791 }
4792}
4793
4794#[derive(Clone, Debug)]
4796pub(crate) struct TextIndexRow {
4797 path: String,
4799 text: Option<IndexedFileText>,
4801 reason: TextIndexSkipReason,
4803}
4804
4805pub(crate) struct TextIndexRefresh {
4807 pub(crate) report: TextIndexReport,
4809 pub(crate) rows: Vec<TextIndexRow>,
4811}
4812
4813#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4815pub(crate) enum TextIndexSkipReason {
4816 Indexed,
4818 TooLarge,
4820 BinaryOrNonUtf8,
4822}
4823
4824#[derive(Debug, Serialize)]
4826pub(crate) struct SymbolBuildReport {
4827 pub(crate) candidates: usize,
4829 pub(crate) parsed: usize,
4831 pub(crate) unchanged: usize,
4833 pub(crate) too_large: usize,
4835 pub(crate) binary_or_non_utf8: usize,
4837 pub(crate) timed_out: usize,
4839 pub(crate) max_workers: usize,
4841 pub(crate) timeout_seconds: Option<u64>,
4843 pub(crate) symbols: usize,
4845 pub(crate) relations: usize,
4847 pub(crate) summaries: usize,
4849 pub(crate) purpose_suggestions: usize,
4851}
4852
4853struct IndexPublicationBatch {
4855 base_generation: IndexGeneration,
4857 contract_fingerprint: String,
4859 root: PathBuf,
4861 nodes: NodePublicationBatch,
4863 purpose_import: Option<PurposeImportSnapshot>,
4865 text_paths: Vec<String>,
4867 text: TextIndexRefresh,
4869 content_classifications: Vec<FileContentClassification>,
4871 symbols: SymbolBuildStage,
4873 graph: graph_projection::StagedRepositoryGraph,
4875 structural_summaries: StructuralSummaryStage,
4877}
4878
4879enum NodePublicationBatch {
4881 Full {
4883 nodes: Vec<Node>,
4885 },
4886 Incremental {
4888 nodes: Vec<Node>,
4890 absent_paths: Vec<String>,
4892 expected_nodes: Vec<Node>,
4894 },
4895}
4896
4897impl NodePublicationBatch {
4898 fn expected_nodes(&self) -> &[Node] {
4900 match self {
4901 Self::Full { nodes } => nodes,
4902 Self::Incremental { expected_nodes, .. } => expected_nodes,
4903 }
4904 }
4905}
4906
4907struct IndexPublicationOutcome {
4909 purpose_import: PurposeImportReport,
4911 text_index: TextIndexReport,
4913 structural_summaries: StructuralSummaryReport,
4915 symbols: SymbolBuildReport,
4917}
4918
4919struct SymbolBuildStage {
4921 report: SymbolBuildReport,
4923 changes: Vec<SymbolProjectionChange>,
4925 retained_bytes: u64,
4927 identity_admission: graph_projection::GraphIdentityAdmission,
4929}
4930
4931enum SymbolProjectionChange {
4933 Parsed(SymbolParseSuccess),
4935 Clear {
4937 path: String,
4939 language: Option<String>,
4941 },
4942}
4943
4944struct StructuralSummaryStage {
4946 report: StructuralSummaryReport,
4948 changes: Vec<StructuralSummaryChange>,
4950 retained_bytes: u64,
4952}
4953
4954#[derive(Default)]
4956struct StructuralSummaryDerivation {
4957 change: Option<StructuralSummaryChange>,
4959 summarized: usize,
4961 cleared: usize,
4963 too_large: usize,
4965 binary_or_non_utf8: usize,
4967 purpose_suggestions: usize,
4969 retained_bytes: u64,
4971}
4972
4973enum StructuralSummaryChange {
4975 Set {
4977 path: String,
4979 summary: String,
4981 purpose_suggestion: Option<String>,
4983 },
4984 Clear {
4986 path: String,
4988 },
4989}
4990
4991#[derive(Debug, Serialize)]
4993pub(crate) struct WatchReport {
4994 pub(crate) mode: String,
4996 pub(crate) cycles: usize,
4998 pub(crate) once: bool,
5000 #[serde(skip_serializing_if = "Option::is_none")]
5002 pub(crate) fallback_reason: Option<String>,
5003 pub(crate) text_index: TextIndexReport,
5005 pub(crate) structural_summaries: StructuralSummaryReport,
5007 pub(crate) last_symbols: SymbolBuildReport,
5009}
5010
5011#[derive(Debug, Default)]
5013pub(crate) struct WatchChangeSet {
5014 requires_full_scan: bool,
5016 paths: HashSet<PathBuf>,
5018 document_paths: HashSet<PathBuf>,
5021}
5022
5023impl WatchChangeSet {
5024 fn has_changes(&self) -> bool {
5026 self.requires_full_scan || !self.paths.is_empty() || !self.document_paths.is_empty()
5027 }
5028
5029 fn merge(&mut self, other: Self) {
5031 self.requires_full_scan |= other.requires_full_scan;
5032 self.paths.extend(other.paths);
5033 self.document_paths.extend(other.document_paths);
5034 }
5035}
5036
5037#[derive(Debug, Serialize)]
5039pub(crate) struct LegacyPurposeReport {
5040 pub(crate) applied: bool,
5042 pub(crate) purpose_files_found: usize,
5044 pub(crate) purpose_files_removed: usize,
5046 pub(crate) source_header_candidates: Vec<String>,
5048 pub(crate) purpose_files: Vec<String>,
5050}
5051
5052#[derive(Debug, Serialize)]
5054pub(crate) struct SettingsReport {
5055 pub(crate) cache_dir: PathStatus,
5057 pub(crate) db: PathStatus,
5059 pub(crate) db_wal: PathStatus,
5061 pub(crate) db_shm: PathStatus,
5063 pub(crate) db_journal: PathStatus,
5065 pub(crate) mcp_config: PathStatus,
5067 pub(crate) config_path: Option<String>,
5069 pub(crate) repo_root: Option<String>,
5071 pub(crate) root_detection_source: String,
5073 pub(crate) root_verified: bool,
5075 pub(crate) root_mismatches: Vec<String>,
5077 pub(crate) map_path: Option<String>,
5079 pub(crate) nonsource_files_path: Option<String>,
5081 pub(crate) default_format: String,
5083 pub(crate) default_search_case_sensitive: bool,
5085 pub(crate) search_source: String,
5087 pub(crate) text_index_max_bytes: u64,
5089 pub(crate) watcher: WatchStatusReport,
5091 pub(crate) index: Option<SettingsIndexStats>,
5093 pub(crate) telemetry: Option<TelemetryRetentionState>,
5095 pub(crate) database: DatabaseSettingsReport,
5097 pub(crate) language_registry: LanguageRegistryReport,
5099 pub(crate) classified_navigation: SettingsClassifiedNavigationReport,
5101 pub(crate) semantic_relation_contract_digest: String,
5103 pub(crate) relation_family_inventory: RelationFamilyInventoryReport,
5105 pub(crate) search: SettingsSearchReport,
5107 pub(crate) optional_parser_pack: OptionalParserSettingsReport,
5109}
5110
5111#[derive(Clone, Debug, Serialize)]
5113pub(crate) struct SettingsClassifiedNavigationReport {
5114 pub(crate) classifications: [ContentClassification; 5],
5116 pub(crate) selections: [ContentSelection; 3],
5118 pub(crate) document_relation: &'static str,
5120 pub(crate) inbound_document_view: &'static str,
5122}
5123
5124pub(crate) fn classified_navigation_capabilities() -> SettingsClassifiedNavigationReport {
5126 SettingsClassifiedNavigationReport {
5127 classifications: [
5128 ContentClassification::Source,
5129 ContentClassification::Documentation,
5130 ContentClassification::ConfigurationData,
5131 ContentClassification::OtherText,
5132 ContentClassification::Opaque,
5133 ],
5134 selections: [
5135 ContentSelection::Source,
5136 ContentSelection::Documentation,
5137 ContentSelection::Both,
5138 ],
5139 document_relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents).as_str(),
5140 inbound_document_view: "documented_by",
5141 }
5142}
5143
5144#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5146#[serde(rename_all = "snake_case")]
5147pub(crate) enum SettingsCapabilityState {
5148 Ready,
5150 Empty,
5152 Unavailable,
5154}
5155
5156#[derive(Debug, Serialize)]
5158pub(crate) struct SettingsSearchModeReport {
5159 pub(crate) state: SettingsCapabilityState,
5161}
5162
5163#[derive(Debug, Serialize)]
5165pub(crate) struct SettingsLexicalSearchReport {
5166 pub(crate) state: SettingsCapabilityState,
5168 pub(crate) source: &'static str,
5170 pub(crate) exact_verification: bool,
5172}
5173
5174#[derive(Debug, Serialize)]
5176pub(crate) struct SettingsSearchReport {
5177 pub(crate) default_mode: &'static str,
5179 pub(crate) lexical: SettingsLexicalSearchReport,
5181 pub(crate) fts: SettingsSearchModeReport,
5183 pub(crate) semantic: SettingsSearchModeReport,
5185 pub(crate) hybrid: SettingsSearchModeReport,
5187}
5188
5189#[derive(Debug, Serialize)]
5191pub(crate) struct OptionalParserSettingsReport {
5192 pub(crate) compiled: bool,
5194 #[cfg(feature = "optional-parser-supervisor")]
5196 pub(crate) lifecycle: OptionalParserPackLifecycleReport,
5197 #[cfg(not(feature = "optional-parser-supervisor"))]
5199 pub(crate) state: &'static str,
5200}
5201
5202#[derive(Debug, Serialize)]
5204pub(crate) struct PathStatus {
5205 pub(crate) path: Option<String>,
5207 pub(crate) exists: bool,
5209 pub(crate) size_bytes: Option<u64>,
5211}
5212
5213#[derive(Debug, Serialize)]
5215pub(crate) struct SettingsIndexStats {
5216 pub(crate) project_root: Option<String>,
5218 pub(crate) files: usize,
5220 pub(crate) folders: usize,
5222 pub(crate) missing_purposes: usize,
5224 pub(crate) stale_purposes: usize,
5226 pub(crate) suggested_purposes: usize,
5228 pub(crate) indexed_text_files: usize,
5230 pub(crate) indexed_text_bytes: usize,
5232 pub(crate) symbols: usize,
5234 pub(crate) relations: usize,
5236 pub(crate) token_calls: usize,
5238 pub(crate) health_findings: usize,
5240}
5241
5242#[derive(Debug, Serialize)]
5244pub(crate) struct WatchStatusReport {
5245 pub(crate) available: bool,
5247 pub(crate) active: bool,
5249 pub(crate) mode: String,
5251 pub(crate) event_backend_available: bool,
5253 pub(crate) recommendation: String,
5255}
5256
5257#[derive(Debug, Serialize)]
5259pub(crate) struct ResetIndexReport {
5260 pub(crate) applied: bool,
5262 pub(crate) dry_run: bool,
5264 files: Vec<PathStatus>,
5266 pub(crate) removed: usize,
5268}
5269
5270pub(crate) fn build_settings_report(
5272 db: &Path,
5273 config_path: Option<&Path>,
5274 format: OutputFormat,
5275) -> Result<SettingsReport, CliError> {
5276 let absolute_db = absolute_path(db)?;
5277 let resolved_config = resolved_mcp_config_path(&absolute_db, config_path)?;
5278 let config = if let Some(config_path) = resolved_config.as_deref() {
5279 load_atlas_config(Some(config_path))?
5280 } else {
5281 let project_root = default_mcp_project_root(&absolute_db, None)?;
5282 load_atlas_config_for_root(&project_root)?
5283 };
5284 let config_root_identity = CanonicalProjectRoot::from_path(&config.root).ok();
5285 let cache_dir = absolute_db
5286 .parent()
5287 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
5288 let database = database_settings_report(&absolute_db)?;
5289 let (index, telemetry, file_text_fts, db_root_identity, db_root_matches_config) =
5290 if database.schema.compatibility == DatabaseSchemaCompatibility::Current {
5291 let store = AtlasStore::open_read_only(&absolute_db)?;
5292 let db_root_identity = store.project_root_identity()?;
5293 let db_root_matches_config = config_root_identity
5294 .as_ref()
5295 .is_some_and(|config_root| store.project_root_identity_matches(config_root));
5296 let snapshot_publication = store.index_publication()?;
5297 if settings_publication_matches(
5298 database.publication.as_ref(),
5299 snapshot_publication.as_ref(),
5300 ) {
5301 (
5302 Some(settings_index_stats(&store)?),
5303 Some(store.telemetry_retention_state()?),
5304 Some(store.file_text_fts_state()?),
5305 db_root_identity,
5306 db_root_matches_config,
5307 )
5308 } else {
5309 (None, None, None, db_root_identity, db_root_matches_config)
5310 }
5311 } else {
5312 (None, None, None, None, false)
5313 };
5314 let repo_root = config_root_identity
5315 .as_ref()
5316 .and_then(|root| root.display_string().ok());
5317 let mut root_mismatches = Vec::new();
5318 if let (Some(db_root), Some(config_root)) =
5319 (db_root_identity.as_ref(), config_root_identity.as_ref())
5320 {
5321 if !db_root_matches_config {
5322 root_mismatches.push(format!(
5323 "db root {:?} does not match config root {:?}",
5324 db_root
5325 .display_string()
5326 .unwrap_or_else(|_| "<native display unavailable>".to_string()),
5327 config_root
5328 .display_string()
5329 .unwrap_or_else(|_| "<native display unavailable>".to_string())
5330 ));
5331 }
5332 } else if db_root_identity.is_some() != config_root_identity.is_some() {
5333 root_mismatches
5334 .push("db root and config root do not both have a usable native identity".to_string());
5335 }
5336 let root_detection_source = if resolved_config.is_some() {
5337 "config"
5338 } else if db_root_identity.is_some() {
5339 "db"
5340 } else {
5341 "db-path-or-cwd"
5342 }
5343 .to_string();
5344 let lexical_publication_ready = database.publication.as_ref().is_some_and(|publication| {
5345 publication.state == IndexPublicationState::Complete
5346 && publication.generation != IndexGeneration::ZERO
5347 && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
5348 });
5349 let lexical_state = match (lexical_publication_ready, index.as_ref()) {
5350 (true, Some(stats)) if stats.indexed_text_files == 0 => SettingsCapabilityState::Empty,
5351 (true, Some(_)) => SettingsCapabilityState::Ready,
5352 _ => SettingsCapabilityState::Unavailable,
5353 };
5354 let fts_state = match (lexical_publication_ready, file_text_fts.as_ref()) {
5355 (true, Some(state)) if !state.synchronized => SettingsCapabilityState::Unavailable,
5356 (true, Some(state)) if state.source_rows == 0 => SettingsCapabilityState::Empty,
5357 (true, Some(_)) => SettingsCapabilityState::Ready,
5358 _ => SettingsCapabilityState::Unavailable,
5359 };
5360 let search = SettingsSearchReport {
5361 default_mode: "lexical",
5362 lexical: SettingsLexicalSearchReport {
5363 state: lexical_state,
5364 source: "persisted_text",
5365 exact_verification: true,
5366 },
5367 fts: SettingsSearchModeReport { state: fts_state },
5368 semantic: SettingsSearchModeReport {
5369 state: SettingsCapabilityState::Unavailable,
5370 },
5371 hybrid: SettingsSearchModeReport {
5372 state: SettingsCapabilityState::Unavailable,
5373 },
5374 };
5375 #[cfg(feature = "optional-parser-supervisor")]
5376 let optional_parser_pack = OptionalParserSettingsReport {
5377 compiled: true,
5378 lifecycle: OptionalParserPackLifecycle::new(&config.root, None)?.status()?,
5379 };
5380 #[cfg(not(feature = "optional-parser-supervisor"))]
5381 let optional_parser_pack = OptionalParserSettingsReport {
5382 compiled: false,
5383 state: "compiled_unavailable",
5384 };
5385 Ok(SettingsReport {
5386 cache_dir: path_status(&cache_dir)?,
5387 db: path_status(&absolute_db)?,
5388 db_wal: path_status(&db_sidecar_path(&absolute_db, "wal"))?,
5389 db_shm: path_status(&db_sidecar_path(&absolute_db, "shm"))?,
5390 db_journal: path_status(&db_sidecar_path(&absolute_db, "journal"))?,
5391 mcp_config: path_status(&mcp_config_path_for_db(&absolute_db))?,
5392 config_path: resolved_config
5393 .as_deref()
5394 .and_then(lossless_native_path_display),
5395 repo_root,
5396 root_detection_source,
5397 root_verified: root_mismatches.is_empty(),
5398 root_mismatches,
5399 map_path: lossless_native_path_display(&config.map_path),
5400 nonsource_files_path: lossless_native_path_display(&config.nonsource_files_path),
5401 default_format: format!("{format:?}").to_ascii_lowercase(),
5402 default_search_case_sensitive: false,
5403 search_source: "sqlite-file-text".to_string(),
5404 text_index_max_bytes: config.text_index_max_bytes(),
5405 watcher: watcher_status_report(false),
5406 index,
5407 telemetry,
5408 database,
5409 language_registry: language_registry_report(),
5410 classified_navigation: classified_navigation_capabilities(),
5411 semantic_relation_contract_digest: semantic_resolution_contract_digest(),
5412 relation_family_inventory: relation_family_inventory_report(),
5413 search,
5414 optional_parser_pack,
5415 })
5416}
5417
5418fn settings_publication_matches(
5420 diagnostic: Option<&DatabasePublicationReport>,
5421 snapshot: Option<&IndexPublication>,
5422) -> bool {
5423 match (diagnostic, snapshot) {
5424 (None, None) => true,
5425 (Some(diagnostic), Some(snapshot))
5426 if diagnostic.state == snapshot.state
5427 && diagnostic.generation == snapshot.generation =>
5428 {
5429 match diagnostic.contract_fingerprint_state {
5430 DatabasePublicationContractState::Missing => {
5431 snapshot.contract_fingerprint.is_none()
5432 }
5433 DatabasePublicationContractState::Valid => {
5434 diagnostic.contract_fingerprint.as_deref()
5435 == snapshot.contract_fingerprint.as_deref()
5436 }
5437 DatabasePublicationContractState::Invalid => false,
5438 }
5439 }
5440 _ => false,
5441 }
5442}
5443
5444pub(crate) fn settings_index_stats(store: &AtlasStore) -> Result<SettingsIndexStats, CliError> {
5446 let overview = store.overview()?;
5447 let health_findings = store.unresolved_health_finding_count_current()?;
5448 Ok(SettingsIndexStats {
5449 project_root: store
5450 .project_root_identity()?
5451 .and_then(|root| root.display_string().ok()),
5452 files: overview.files,
5453 folders: overview.folders,
5454 missing_purposes: overview.missing_purposes,
5455 stale_purposes: overview.stale_purposes,
5456 suggested_purposes: overview.suggested_purposes,
5457 indexed_text_files: store.file_text_count()?,
5458 indexed_text_bytes: store.file_text_byte_count()?,
5459 symbols: store.symbol_count()?,
5460 relations: store.symbol_relation_count()?,
5461 token_calls: store.token_overview(None)?.calls,
5462 health_findings,
5463 })
5464}
5465
5466pub(crate) fn reset_index_files(
5468 db: &Path,
5469 apply: bool,
5470 dry_run: bool,
5471 include_mcp_config: bool,
5472) -> Result<ResetIndexReport, CliError> {
5473 let targets = reset_index_targets(db, include_mcp_config)?;
5474 let files = targets
5475 .iter()
5476 .map(|path| path_status(path))
5477 .collect::<Result<Vec<_>, _>>()?;
5478 let should_apply = apply && !dry_run;
5479 let mut removed = 0;
5480 if should_apply {
5481 for target in &targets {
5482 if target.is_file() {
5483 fs::remove_file(target).map_err(|source| CliError::Io {
5484 path: target.clone(),
5485 source,
5486 })?;
5487 removed += 1;
5488 }
5489 }
5490 }
5491 Ok(ResetIndexReport {
5492 applied: should_apply,
5493 dry_run: !should_apply,
5494 files,
5495 removed,
5496 })
5497}
5498
5499pub(crate) fn reset_index_files_with_revalidation(
5501 db: &Path,
5502 include_mcp_config: bool,
5503 mut revalidate: impl FnMut() -> Result<(), CliError>,
5504) -> Result<ResetIndexReport, CliError> {
5505 let targets = reset_index_targets(db, include_mcp_config)?;
5506 let files = targets
5507 .iter()
5508 .map(|path| path_status(path))
5509 .collect::<Result<Vec<_>, _>>()?;
5510 if !targets.iter().any(|target| target.is_file()) {
5511 revalidate()?;
5512 return Ok(ResetIndexReport {
5513 applied: true,
5514 dry_run: false,
5515 files,
5516 removed: 0,
5517 });
5518 }
5519
5520 let absolute_db = absolute_path(db)?;
5521 let parent = absolute_db.parent().ok_or_else(|| {
5522 CliError::InvalidInput("reset database path has no parent directory".to_string())
5523 })?;
5524 let recovery = tempfile::Builder::new()
5525 .prefix("reset-recovery-")
5526 .tempdir_in(parent)
5527 .map_err(|source| CliError::Io {
5528 path: parent.to_path_buf(),
5529 source,
5530 })?
5531 .keep();
5532 let mut staged = Vec::with_capacity(targets.len());
5533 for (index, target) in targets.iter().enumerate() {
5534 if !target.is_file() {
5535 continue;
5536 }
5537 let recovery_file = recovery.join(format!("{index:02}.reset"));
5538 if let Err(source) = move_reset_file_noclobber(target, &recovery_file) {
5539 let operation = revalidate().err().unwrap_or_else(|| CliError::Io {
5540 path: target.clone(),
5541 source,
5542 });
5543 return Err(restore_staged_reset_files(&recovery, &staged, operation));
5544 }
5545 staged.push((target.clone(), recovery_file));
5546 }
5547
5548 if let Err(error) = revalidate() {
5549 return Err(restore_staged_reset_files(&recovery, &staged, error));
5550 }
5551
5552 let removed = staged.len();
5553 for (_, recovery_file) in &staged {
5554 if let Err(source) = fs::remove_file(recovery_file) {
5555 return Err(CliError::Io {
5556 path: recovery,
5557 source,
5558 });
5559 }
5560 }
5561 fs::remove_dir(&recovery).map_err(|source| CliError::Io {
5562 path: recovery,
5563 source,
5564 })?;
5565 Ok(ResetIndexReport {
5566 applied: true,
5567 dry_run: false,
5568 files,
5569 removed,
5570 })
5571}
5572
5573fn reset_index_targets(db: &Path, include_mcp_config: bool) -> Result<Vec<PathBuf>, CliError> {
5575 let absolute_db = absolute_path(db)?;
5576 let mut targets = vec![
5577 absolute_db.clone(),
5578 db_sidecar_path(&absolute_db, "wal"),
5579 db_sidecar_path(&absolute_db, "shm"),
5580 db_sidecar_path(&absolute_db, "journal"),
5581 ];
5582 if include_mcp_config {
5583 targets.push(mcp_config_path_for_db(&absolute_db));
5584 }
5585 targets.sort();
5586 targets.dedup();
5587 Ok(targets)
5588}
5589
5590fn move_reset_file_noclobber(source: &Path, destination: &Path) -> io::Result<()> {
5592 let temporary = tempfile::TempPath::try_from_path(source)?;
5593 match temporary.persist_noclobber(destination) {
5594 Ok(()) => Ok(()),
5595 Err(mut error) => {
5596 error.path.disable_cleanup(true);
5597 Err(error.error)
5598 }
5599 }
5600}
5601
5602fn restore_staged_reset_files(
5604 recovery: &Path,
5605 staged: &[(PathBuf, PathBuf)],
5606 operation: CliError,
5607) -> CliError {
5608 for (target, recovery_file) in staged.iter().rev() {
5609 if let Err(source) = move_reset_file_noclobber(recovery_file, target) {
5610 let kind = source.kind();
5611 return CliError::Io {
5612 path: recovery.to_path_buf(),
5613 source: io::Error::new(
5614 kind,
5615 format!("{operation}; reset recovery restore failed: {source}"),
5616 ),
5617 };
5618 }
5619 }
5620 drop(fs::remove_dir(recovery));
5622 operation
5623}
5624
5625pub(crate) fn resolved_mcp_config_path(
5627 db: &Path,
5628 config: Option<&Path>,
5629) -> Result<Option<PathBuf>, CliError> {
5630 let legacy_root = legacy_project_root_candidate(db)?;
5631 if let Some(path) = config {
5632 return Ok(Some(absolute_path(path)?));
5633 }
5634 let mut candidate_roots = Vec::new();
5635 if db.exists()
5636 && let Some(project_root) = read_project_root_identity_read_only(db)?
5637 {
5638 candidate_roots.push(project_root.into_path());
5639 }
5640 if let Some(project_root) = legacy_root {
5641 candidate_roots.push(project_root);
5642 }
5643 let absolute_db = absolute_path(db)?;
5644 if let Some(project_root) = project_root_from_db_path(&absolute_db) {
5645 candidate_roots.push(project_root);
5646 }
5647 for root in candidate_roots {
5648 for candidate in config_candidates_for_root(&root) {
5649 if candidate.exists() {
5650 return Ok(Some(absolute_path(&candidate)?));
5651 }
5652 }
5653 }
5654 Ok(None)
5655}
5656
5657fn config_candidates_for_root(root: &Path) -> [PathBuf; 2] {
5659 [
5660 root.join(".projectatlas").join("config.toml"),
5661 root.join("projectatlas.toml"),
5662 ]
5663}
5664
5665pub(crate) fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
5667 if path.is_absolute() {
5668 return Ok(path.to_path_buf());
5669 }
5670 let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
5671 path: PathBuf::from("."),
5672 source,
5673 })?;
5674 Ok(current_dir.join(path))
5675}
5676
5677pub(crate) fn path_status(path: &Path) -> Result<PathStatus, CliError> {
5679 let absolute = absolute_path(path)?;
5680 let metadata = fs::metadata(&absolute).ok();
5681 Ok(PathStatus {
5682 path: lossless_native_path_display(&absolute),
5683 exists: metadata.is_some(),
5684 size_bytes: metadata
5685 .as_ref()
5686 .and_then(|metadata| metadata.is_file().then_some(metadata.len())),
5687 })
5688}
5689
5690pub(crate) fn db_sidecar_path(db: &Path, suffix: &str) -> PathBuf {
5692 let mut sidecar = db.as_os_str().to_os_string();
5693 sidecar.push(format!("-{suffix}"));
5694 PathBuf::from(sidecar)
5695}
5696
5697pub(crate) fn mcp_config_path_for_db(db: &Path) -> PathBuf {
5699 db.parent().map_or_else(
5700 || PathBuf::from("projectatlas.mcp.json"),
5701 |parent| parent.join("projectatlas.mcp.json"),
5702 )
5703}
5704
5705pub(crate) fn watcher_status_report(active: bool) -> WatchStatusReport {
5707 let notify_available = notify_runtime_available();
5708 let mode = if notify_available {
5709 WATCH_MODE_NOTIFY
5710 } else {
5711 WATCH_MODE_POLLING
5712 };
5713 let recommendation = if notify_available {
5714 "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for event-backed refresh with portable polling fallback."
5715 } else {
5716 "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for portable polling refresh."
5717 };
5718 WatchStatusReport {
5719 available: true,
5720 active,
5721 mode: mode.to_string(),
5722 event_backend_available: notify_available,
5723 recommendation: recommendation.to_string(),
5724 }
5725}
5726
5727#[derive(Debug, Eq, PartialEq, Serialize)]
5729pub(crate) struct DatabaseLintReport {
5730 pub(crate) purpose_level: String,
5732 pub(crate) shown: usize,
5734 pub(crate) total: usize,
5736 pub(crate) findings: Vec<HealthFinding>,
5738}
5739
5740impl DatabaseLintReport {
5741 fn render_text(&self) -> Result<String, CliError> {
5743 if self.findings.is_empty() {
5744 return Ok(String::new());
5745 }
5746 let mut report = format!(
5747 "ProjectAtlas SQLite index health findings (purpose-level {}, showing {} of {}):\n",
5748 self.purpose_level, self.shown, self.total
5749 );
5750 for finding in &self.findings {
5751 writeln!(
5752 &mut report,
5753 "- [{}] {}: {}",
5754 finding.category, finding.path, finding.recommendation
5755 )
5756 .map_err(|source| CliError::Output(io::Error::other(source.to_string())))?;
5757 }
5758 Ok(report)
5759 }
5760}
5761
5762#[derive(Debug, Eq, PartialEq, Serialize)]
5764pub(crate) struct LintReport {
5765 pub(crate) ok: bool,
5767 pub(crate) exit_code: i32,
5769 pub(crate) map: atlas_map::MapLintReport,
5771 pub(crate) index: Option<DatabaseLintReport>,
5773 pub(crate) report: String,
5775}
5776
5777pub(crate) fn lint_project(
5779 config: &atlas_map::AtlasMapConfig,
5780 db: &Path,
5781 config_path: Option<&Path>,
5782 options: atlas_map::LintOptions,
5783 purpose_level: PurposeLintLevel,
5784) -> Result<LintReport, CliError> {
5785 let map = atlas_map::lint_map(config, options)?;
5786 let index = lint_database_if_present(db, &config.root, config_path, purpose_level)?;
5787 let exit_code = map.exit_code().max(i32::from(
5788 index
5789 .as_ref()
5790 .is_some_and(|report| !report.findings.is_empty()),
5791 ));
5792 let mut report = map.render_text();
5793 if let Some(index) = &index {
5794 let index_text = index.render_text()?;
5795 if !index_text.is_empty() {
5796 if !report.is_empty() && !report.ends_with('\n') {
5797 report.push('\n');
5798 }
5799 report.push_str(&index_text);
5800 }
5801 }
5802 Ok(LintReport {
5803 ok: exit_code == 0,
5804 exit_code,
5805 map,
5806 index,
5807 report,
5808 })
5809}
5810
5811pub(crate) fn lint_database_if_present(
5813 db: &Path,
5814 root: &Path,
5815 config_path: Option<&Path>,
5816 purpose_level: PurposeLintLevel,
5817) -> Result<Option<DatabaseLintReport>, CliError> {
5818 match db.try_exists() {
5819 Ok(false) => return Ok(None),
5820 Ok(true) => {}
5821 Err(source) => {
5822 return Err(CliError::Io {
5823 path: db.to_path_buf(),
5824 source,
5825 });
5826 }
5827 }
5828 let control = standalone_index_work_control();
5829 let exact = open_exact_saved_source_matches_index_controlled(db, root, config_path, &control)?;
5830 let store = &exact.store;
5831 let query = purpose_level.health_query();
5832 let page = store.unresolved_health_findings_page_current(&query)?;
5833 let blocking = page
5834 .findings
5835 .into_iter()
5836 .filter(|finding| purpose_level.blocks_category(finding.category.as_str()))
5837 .collect::<Vec<_>>();
5838 let report = DatabaseLintReport {
5839 purpose_level: purpose_level.as_str().to_string(),
5840 shown: blocking.len(),
5841 total: page.total,
5842 findings: blocking,
5843 };
5844 store.finish_index_read_snapshot()?;
5845 Ok(Some(report))
5846}
5847
5848#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5850pub(crate) enum PurposeLintLevel {
5851 Low,
5853 Medium,
5855 Strict,
5857}
5858
5859impl PurposeLintLevel {
5860 pub(crate) fn as_str(self) -> &'static str {
5862 match self {
5863 Self::Low => "low",
5864 Self::Medium => "medium",
5865 Self::Strict => "strict",
5866 }
5867 }
5868
5869 fn health_query(self) -> HealthQuery {
5871 let scope = match self {
5872 Self::Low => HealthScope::purpose_default(),
5873 Self::Medium => HealthScope::purpose_with_source_files(),
5874 Self::Strict => HealthScope::purpose_strict(),
5875 };
5876 HealthQuery {
5877 start_index: 0,
5878 limit: MAX_HEALTH_LIMIT,
5879 category: None,
5880 severity: Some(Severity::Warning),
5881 path_prefix: None,
5882 summary_only: false,
5883 scope,
5884 }
5885 }
5886
5887 fn blocks_category(self, category: &str) -> bool {
5889 match category {
5890 CATEGORY_STALE_PURPOSE
5891 | CATEGORY_DUPLICATE_PURPOSE
5892 | CATEGORY_REPEATED_TEMPORARY_FOLDER => true,
5893 CATEGORY_MISSING_PURPOSE
5894 | CATEGORY_SUGGESTED_PURPOSE_REVIEW
5895 | CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self != Self::Low,
5896 _ => false,
5897 }
5898 }
5899}
5900
5901pub(crate) fn notify_runtime_available() -> bool {
5903 let (sender, _receiver) = mpsc::channel();
5904 RecommendedWatcher::new(
5905 move |result: notify::Result<Event>| {
5906 if sender.send(result).is_err() {
5907 }
5909 },
5910 Config::default(),
5911 )
5912 .is_ok()
5913}
5914
5915#[derive(Clone, Copy, Debug)]
5917pub(crate) struct SymbolBuildOptions {
5918 pub(crate) max_bytes: u64,
5920 max_workers: Option<usize>,
5922 timeout: Option<Duration>,
5924 pub(crate) timeout_seconds: Option<u64>,
5926}
5927
5928impl SymbolBuildOptions {
5929 pub(crate) fn new(
5931 max_bytes: u64,
5932 max_workers: Option<usize>,
5933 timeout_seconds: Option<u64>,
5934 ) -> Self {
5935 Self {
5936 max_bytes: max_bytes.min(MAX_SYMBOL_FILE_BYTES),
5937 max_workers: max_workers.filter(|workers| *workers > 0),
5938 timeout: timeout_seconds.map(Duration::from_secs),
5939 timeout_seconds,
5940 }
5941 }
5942
5943 #[must_use]
5945 pub(crate) fn with_worker_ceiling(mut self, max_workers: usize) -> Self {
5946 let ceiling = max_workers.max(1);
5947 self.max_workers = Some(
5948 self.max_workers
5949 .map_or(ceiling, |workers| workers.min(ceiling)),
5950 );
5951 self
5952 }
5953
5954 pub(crate) fn reported_workers(self) -> usize {
5956 self.effective_workers()
5957 }
5958
5959 fn effective_workers(self) -> usize {
5961 let available = thread::available_parallelism().map_or(1, usize::from);
5962 self.max_workers
5963 .unwrap_or(available)
5964 .min(available)
5965 .min(INDEX_WORKER_SAFE_CEILING)
5966 }
5967
5968 pub(crate) fn is_timed_out(self, started_at: Instant) -> bool {
5970 self.timeout
5971 .is_some_and(|timeout| started_at.elapsed() >= timeout)
5972 }
5973}
5974
5975fn worker_count_for_work(work_items: usize, max_workers: usize) -> usize {
5977 work_items.min(max_workers.clamp(1, INDEX_WORKER_SAFE_CEILING))
5978}
5979
5980#[derive(Clone, Copy, Debug)]
5982struct SymbolPublicationLimits {
5983 symbol_rows: u64,
5985 relation_rows: u64,
5987 output_bytes: u64,
5989}
5990
5991impl SymbolPublicationLimits {
5992 const STANDARD: Self = Self {
5994 symbol_rows: 2_000_000,
5995 relation_rows: 8_000_000,
5996 output_bytes: MAX_PUBLICATION_STAGING_BYTES,
5997 };
5998}
5999
6000pub(crate) fn index_work_control(options: &SymbolBuildOptions) -> IndexWorkControl {
6002 IndexWorkControl::new(IndexCancellation::new(), options.timeout)
6003 .with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6004 .with_worker_ceiling(options.effective_workers())
6005}
6006
6007pub(crate) fn standalone_index_work_control() -> IndexWorkControl {
6009 IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_INDEX_WORK_TIMEOUT))
6010}
6011
6012fn bounded_index_work_control(control: &IndexWorkControl) -> IndexWorkControl {
6014 control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6015}
6016
6017#[derive(Clone, Debug)]
6019pub(crate) struct SymbolParseJob {
6020 pub(crate) path: String,
6022 native_path: PathBuf,
6024 expected_content_hash: String,
6026 language: Option<String>,
6028 fallback_summary: Option<String>,
6030 purpose_needs_suggestion: bool,
6032}
6033
6034#[derive(Debug)]
6036pub(crate) struct SymbolParseSuccess {
6037 pub(crate) path: String,
6039 graph: SymbolGraph,
6041 markdown_facts: Option<Box<MarkdownFacts>>,
6043 source_parser: ParserKind,
6045 summary: String,
6047 summary_is_structural: bool,
6049 purpose_suggestion: Option<String>,
6051}
6052
6053#[derive(Debug)]
6055pub(crate) enum SymbolParseOutcome {
6056 Parsed(SymbolParseSuccess),
6058 BinaryOrNonUtf8 {
6060 path: String,
6062 },
6063 InvalidInput {
6065 path: String,
6067 message: String,
6069 },
6070 SourceChanged {
6072 path: String,
6074 },
6075 Io {
6077 path: PathBuf,
6079 source: io::Error,
6081 },
6082 IndexWork(IndexWorkFailure),
6084}
6085
6086#[derive(Debug)]
6088enum SourceReadFailure {
6089 Io(io::Error),
6091 IndexWork(IndexWorkFailure),
6093 LimitExceeded {
6095 observed: u64,
6097 },
6098}
6099
6100fn read_source_bytes_controlled(
6102 path: &Path,
6103 max_bytes: u64,
6104 stage: IndexWorkStage,
6105 control: &IndexWorkControl,
6106) -> Result<Vec<u8>, SourceReadFailure> {
6107 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6108 let mut file = fs::File::open(path).map_err(SourceReadFailure::Io)?;
6109 let mut bytes = Vec::new();
6110 let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
6111 loop {
6112 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6113 let count = file.read(&mut buffer).map_err(SourceReadFailure::Io)?;
6114 if count == 0 {
6115 break;
6116 }
6117 let observed = u64::try_from(bytes.len())
6118 .unwrap_or(u64::MAX)
6119 .saturating_add(count as u64);
6120 if observed > max_bytes {
6121 return Err(SourceReadFailure::LimitExceeded { observed });
6122 }
6123 bytes.extend_from_slice(&buffer[..count]);
6124 }
6125 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6126 Ok(bytes)
6127}
6128
6129#[cfg(test)]
6131fn build_symbols_for_paths_with_limits(
6132 store: &mut AtlasStore,
6133 root: &Path,
6134 options: &SymbolBuildOptions,
6135 previous_hashes: Option<&HashMap<String, String>>,
6136 target_paths: Option<&HashSet<String>>,
6137 control: &IndexWorkControl,
6138 limits: SymbolPublicationLimits,
6139) -> Result<SymbolBuildReport, CliError> {
6140 let nodes = if let Some(paths) = target_paths {
6141 let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
6142 sorted_paths.sort();
6143 store
6144 .load_nodes_by_paths(&sorted_paths)?
6145 .into_iter()
6146 .map(|indexed| indexed.node)
6147 .collect::<Vec<_>>()
6148 } else {
6149 store
6150 .load_nodes()?
6151 .into_iter()
6152 .map(|indexed| indexed.node)
6153 .collect::<Vec<_>>()
6154 };
6155 #[cfg(feature = "optional-parser-supervisor")]
6156 let optional_parser_selection =
6157 OptionalParserPackLifecycle::new(root, None)?.derive_project_selection()?;
6158 let staged = stage_symbols_for_nodes_with_limits(
6159 store,
6160 root,
6161 #[cfg(feature = "optional-parser-supervisor")]
6162 &optional_parser_selection,
6163 &nodes,
6164 options,
6165 previous_hashes,
6166 target_paths,
6167 &HashSet::new(),
6168 control,
6169 limits,
6170 )?;
6171 let mut staged = staged;
6172 apply_symbol_build_stage(store, &mut staged, control)?;
6173 Ok(staged.report)
6174}
6175
6176fn source_input_byte_limit(
6178 path: &str,
6179 language: Option<&str>,
6180 observed: Option<u64>,
6181 source_limit: u64,
6182 stage: IndexWorkStage,
6183) -> Result<u64, IndexWorkFailure> {
6184 if document_format_for_path(path, language).is_none() {
6185 return Ok(source_limit);
6186 }
6187 let maximum = MAX_DOCUMENT_COMPRESSED_BYTES as u64;
6188 if let Some(observed) = observed.filter(|observed| *observed > maximum) {
6189 return Err(IndexWorkFailure::resource_limit(
6190 stage,
6191 IndexWorkResource::SourceBytes,
6192 maximum,
6193 observed,
6194 ));
6195 }
6196 Ok(maximum)
6197}
6198
6199#[allow(clippy::too_many_arguments)]
6201fn stage_symbols_for_nodes_with_limits(
6202 store: &AtlasStore,
6203 root: &Path,
6204 #[cfg(feature = "optional-parser-supervisor")]
6205 optional_parser_selection: &OptionalParserPackProjectSelection,
6206 nodes: &[Node],
6207 options: &SymbolBuildOptions,
6208 previous_hashes: Option<&HashMap<String, String>>,
6209 target_paths: Option<&HashSet<String>>,
6210 protected_purpose_paths: &HashSet<String>,
6211 control: &IndexWorkControl,
6212 limits: SymbolPublicationLimits,
6213) -> Result<SymbolBuildStage, CliError> {
6214 control.check(IndexWorkStage::SymbolParsing)?;
6215 #[cfg(feature = "optional-parser-supervisor")]
6216 let admit_optional_languages = optional_parser_selection.selection_key().is_some();
6217 #[cfg(not(feature = "optional-parser-supervisor"))]
6218 let admit_optional_languages = false;
6219 let root = root.canonicalize().map_err(|source| CliError::Io {
6220 path: root.to_path_buf(),
6221 source,
6222 })?;
6223 let considered_paths = nodes
6224 .iter()
6225 .filter(|node| node.kind == NodeKind::File)
6226 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6227 .map(|node| node.path.clone())
6228 .collect::<Vec<_>>();
6229 let previously_parsed_paths = store.source_parse_metadata_paths_for_paths(&considered_paths)?;
6230 let mut candidate_paths = nodes
6231 .iter()
6232 .filter(|node| node.kind == NodeKind::File)
6233 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6234 .filter(|node| {
6235 is_symbol_candidate_for_admission(
6236 &node.path,
6237 node.language.as_deref(),
6238 admit_optional_languages,
6239 )
6240 })
6241 .map(|node| node.path.clone())
6242 .collect::<Vec<_>>();
6243 candidate_paths.sort();
6244 let existing_nodes = store
6245 .load_nodes_by_paths(&candidate_paths)?
6246 .into_iter()
6247 .map(|indexed| (indexed.node.path.clone(), indexed))
6248 .collect::<HashMap<_, _>>();
6249 let symbol_counts = store.symbol_counts_for_paths(&candidate_paths)?;
6250 let mut report = SymbolBuildReport {
6251 candidates: 0,
6252 parsed: 0,
6253 unchanged: 0,
6254 too_large: 0,
6255 binary_or_non_utf8: 0,
6256 timed_out: 0,
6257 max_workers: options.reported_workers(),
6258 timeout_seconds: options.timeout_seconds,
6259 symbols: 0,
6260 relations: 0,
6261 summaries: 0,
6262 purpose_suggestions: 0,
6263 };
6264 let mut jobs = Vec::new();
6265 let mut changes = Vec::new();
6266 let mut output_bytes = 0_u64;
6267 for node in nodes
6268 .iter()
6269 .filter(|node| node.kind == NodeKind::File)
6270 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6271 .filter(|node| {
6272 !is_symbol_candidate_for_admission(
6273 &node.path,
6274 node.language.as_deref(),
6275 admit_optional_languages,
6276 ) && previously_parsed_paths.contains(&node.path)
6277 })
6278 {
6279 output_bytes = checked_symbol_publication_usage(
6280 output_bytes,
6281 node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6282 limits.output_bytes,
6283 IndexWorkResource::OutputBytes,
6284 )?;
6285 changes.push(SymbolProjectionChange::Clear {
6286 path: node.path.clone(),
6287 language: node.language.clone(),
6288 });
6289 }
6290 for node in nodes
6291 .iter()
6292 .filter(|node| node.kind == NodeKind::File)
6293 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6294 .filter(|node| {
6295 is_symbol_candidate_for_admission(
6296 &node.path,
6297 node.language.as_deref(),
6298 admit_optional_languages,
6299 )
6300 })
6301 {
6302 control.check(IndexWorkStage::SymbolParsing)?;
6303 report.candidates += 1;
6304 let max_bytes = source_input_byte_limit(
6305 &node.path,
6306 node.language.as_deref(),
6307 node.size_bytes,
6308 options.max_bytes,
6309 IndexWorkStage::SymbolParsing,
6310 )?;
6311 if node.size_bytes.is_some_and(|size| size > max_bytes) {
6312 output_bytes = checked_symbol_publication_usage(
6313 output_bytes,
6314 node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6315 limits.output_bytes,
6316 IndexWorkResource::OutputBytes,
6317 )?;
6318 changes.push(SymbolProjectionChange::Clear {
6319 path: node.path.clone(),
6320 language: node.language.clone(),
6321 });
6322 report.too_large += 1;
6323 continue;
6324 }
6325 let symbol_count = symbol_counts.get(&node.path).copied().unwrap_or_default();
6326 if node.content_hash.as_ref().is_some_and(|hash| {
6327 previous_hashes.and_then(|hashes| hashes.get(&node.path)) == Some(hash)
6328 }) {
6329 let has_source_index =
6330 symbol_count > 0 || store.load_source_parse_metadata(&node.path)?.is_some();
6331 if has_source_index {
6332 report.unchanged += 1;
6333 continue;
6334 }
6335 }
6336 let existing = existing_nodes.get(&node.path);
6337 jobs.push(SymbolParseJob {
6338 path: node.path.clone(),
6339 native_path: root.join(repo_path_to_native(&node.path)),
6340 expected_content_hash: node
6341 .content_hash
6342 .clone()
6343 .ok_or_else(|| source_changed_during_derivation(&root, &node.path))?,
6344 language: node.language.clone(),
6345 fallback_summary: existing.and_then(|indexed| indexed.summary.clone()),
6346 purpose_needs_suggestion: !protected_purpose_paths.contains(&node.path)
6347 && existing.is_none_or(|indexed| {
6348 matches!(
6349 indexed.purpose.status,
6350 PurposeStatus::Missing | PurposeStatus::Suggested
6351 )
6352 }),
6353 });
6354 if jobs.len() > MAX_SYMBOL_PARSE_JOBS {
6355 return Err(IndexWorkFailure::resource_limit(
6356 IndexWorkStage::SymbolParsing,
6357 IndexWorkResource::SymbolJobs,
6358 MAX_SYMBOL_PARSE_JOBS as u64,
6359 jobs.len() as u64,
6360 )
6361 .into());
6362 }
6363 }
6364 report.max_workers = worker_count_for_work(jobs.len(), report.max_workers);
6365 if !jobs.is_empty() {
6366 let pool = ThreadPoolBuilder::new()
6367 .num_threads(report.max_workers)
6368 .build()
6369 .map_err(|source| {
6370 CliError::InvalidInput(format!("symbol worker pool failed: {source}"))
6371 })?;
6372 #[cfg(feature = "optional-parser-supervisor")]
6373 let outcomes = optional_parser_runtime::parse_symbol_jobs_controlled(
6374 &root,
6375 optional_parser_selection,
6376 &pool,
6377 &jobs,
6378 options,
6379 control,
6380 )?;
6381 #[cfg(not(feature = "optional-parser-supervisor"))]
6382 let outcomes = parse_symbol_job_batches_controlled(&pool, &jobs, options, control)?;
6383 for outcome in outcomes {
6384 match outcome {
6385 SymbolParseOutcome::Parsed(parsed) => {
6386 let next_symbols = checked_symbol_publication_usage(
6387 report.symbols as u64,
6388 parsed.graph.symbols.len() as u64,
6389 limits.symbol_rows,
6390 IndexWorkResource::SymbolRows,
6391 )?;
6392 let next_relations = checked_symbol_publication_usage(
6393 report.relations as u64,
6394 parsed.graph.relations.len() as u64,
6395 limits.relation_rows,
6396 IndexWorkResource::RelationRows,
6397 )?;
6398 let next_output_bytes = checked_symbol_publication_usage(
6399 output_bytes,
6400 symbol_parse_output_bytes(&parsed),
6401 limits.output_bytes,
6402 IndexWorkResource::OutputBytes,
6403 )?;
6404 report.summaries += 1;
6405 if parsed.purpose_suggestion.is_some() {
6406 report.purpose_suggestions += 1;
6407 }
6408 report.parsed += 1;
6409 report.symbols = next_symbols as usize;
6410 report.relations = next_relations as usize;
6411 output_bytes = next_output_bytes;
6412 changes.push(SymbolProjectionChange::Parsed(parsed));
6413 }
6414 SymbolParseOutcome::BinaryOrNonUtf8 { path } => {
6415 let language = nodes
6416 .iter()
6417 .find(|node| node.path == path)
6418 .and_then(|node| node.language.clone());
6419 output_bytes = checked_symbol_publication_usage(
6420 output_bytes,
6421 path.len() as u64 + language.as_ref().map_or(0, String::len) as u64,
6422 limits.output_bytes,
6423 IndexWorkResource::OutputBytes,
6424 )?;
6425 changes.push(SymbolProjectionChange::Clear { path, language });
6426 report.binary_or_non_utf8 += 1;
6427 }
6428 SymbolParseOutcome::InvalidInput { path, message } => {
6429 return Err(CliError::InvalidInput(format!(
6430 "document extraction failed for {path}: {message}"
6431 )));
6432 }
6433 SymbolParseOutcome::SourceChanged { path } => {
6434 return Err(source_changed_during_derivation(&root, &path));
6435 }
6436 SymbolParseOutcome::Io { path, source } => {
6437 return Err(CliError::Io { path, source });
6438 }
6439 SymbolParseOutcome::IndexWork(failure) => return Err(failure.into()),
6440 }
6441 }
6442 }
6443 control.check(IndexWorkStage::SymbolParsing)?;
6444 let mut staged = SymbolBuildStage {
6445 report,
6446 changes,
6447 retained_bytes: output_bytes,
6448 identity_admission: graph_projection::GraphIdentityAdmission::default(),
6449 };
6450 staged.identity_admission = graph_projection::admit_symbol_build_stage(&mut staged, control)?;
6451 Ok(staged)
6452}
6453
6454#[cfg(not(feature = "optional-parser-supervisor"))]
6456fn parse_symbol_job_batches_controlled(
6457 pool: &rayon::ThreadPool,
6458 jobs: &[SymbolParseJob],
6459 options: &SymbolBuildOptions,
6460 control: &IndexWorkControl,
6461) -> Result<Vec<SymbolParseOutcome>, CliError> {
6462 let mut outcomes = Vec::with_capacity(jobs.len());
6463 for batch in jobs.chunks(SYMBOL_PARSE_BATCH_SIZE) {
6464 control.check(IndexWorkStage::SymbolParsing)?;
6465 outcomes.extend(parse_symbol_jobs_controlled(pool, batch, options, control)?);
6466 }
6467 Ok(outcomes)
6468}
6469
6470fn apply_symbol_build_stage(
6472 store: &mut AtlasStore,
6473 staged: &mut SymbolBuildStage,
6474 control: &IndexWorkControl,
6475) -> Result<(), CliError> {
6476 if !staged.identity_admission.source_admitted() {
6477 staged.identity_admission = graph_projection::admit_symbol_build_stage(staged, control)?;
6478 }
6479 for change in &staged.changes {
6480 control.check(IndexWorkStage::Publication)?;
6481 match change {
6482 SymbolProjectionChange::Parsed(parsed) => {
6483 store.set_node_summary(&parsed.path, &parsed.summary)?;
6484 if let Some(suggestion) = parsed.purpose_suggestion.as_deref() {
6485 store.set_suggested_purpose(&parsed.path, suggestion)?;
6486 }
6487 let mut metadata = SourceParseMetadata::from_graph(&parsed.graph);
6488 metadata.parser = parsed.source_parser;
6489 store.replace_symbol_graph_with_metadata(&parsed.graph, &metadata)?;
6490 }
6491 SymbolProjectionChange::Clear { path, language } => {
6492 clear_skipped_symbol_index(store, path, language.as_deref())?;
6493 }
6494 }
6495 }
6496 control.check(IndexWorkStage::Publication)?;
6497 Ok(())
6498}
6499
6500#[cfg(not(feature = "optional-parser-supervisor"))]
6502fn parse_symbol_jobs_controlled(
6503 pool: &rayon::ThreadPool,
6504 jobs: &[SymbolParseJob],
6505 options: &SymbolBuildOptions,
6506 control: &IndexWorkControl,
6507) -> Result<Vec<SymbolParseOutcome>, CliError> {
6508 control.check(IndexWorkStage::SymbolParsing)?;
6509 Ok(pool.install(|| {
6510 jobs.par_iter()
6511 .map(|job| parse_symbol_job_controlled(job, options, control))
6512 .collect::<Vec<_>>()
6513 }))
6514}
6515
6516fn checked_symbol_publication_usage(
6518 current: u64,
6519 added: u64,
6520 limit: u64,
6521 resource: IndexWorkResource,
6522) -> Result<u64, CliError> {
6523 let observed = current.saturating_add(added);
6524 if observed > limit {
6525 return Err(IndexWorkFailure::resource_limit(
6526 IndexWorkStage::SymbolParsing,
6527 resource,
6528 limit,
6529 observed,
6530 )
6531 .into());
6532 }
6533 Ok(observed)
6534}
6535
6536fn symbol_parse_output_bytes(parsed: &SymbolParseSuccess) -> u64 {
6538 let graph = &parsed.graph;
6539 let mut bytes = graph.path.len() as u64
6540 + graph.language.as_ref().map_or(0, String::len) as u64
6541 + parsed.summary.len() as u64
6542 + parsed.purpose_suggestion.as_ref().map_or(0, String::len) as u64;
6543 for symbol in &graph.symbols {
6544 bytes = bytes.saturating_add(
6545 symbol.path.len() as u64
6546 + symbol.language.as_ref().map_or(0, String::len) as u64
6547 + symbol.name.len() as u64
6548 + symbol.signature.len() as u64
6549 + symbol.documentation.as_ref().map_or(0, String::len) as u64
6550 + symbol.parent.as_ref().map_or(0, String::len) as u64
6551 + symbol.detail.as_ref().map_or(0, String::len) as u64,
6552 );
6553 }
6554 for relation in &graph.relations {
6555 bytes = bytes.saturating_add(
6556 relation.path.len() as u64
6557 + relation.source_name.len() as u64
6558 + relation.target_name.len() as u64
6559 + relation.context.len() as u64,
6560 );
6561 }
6562 if let Some(facts) = &parsed.markdown_facts {
6563 for heading in &facts.headings {
6564 bytes = bytes
6565 .saturating_add(heading.text.len() as u64)
6566 .saturating_add(heading.slug.len() as u64);
6567 }
6568 for candidate in &facts.link_candidates {
6569 bytes = bytes
6570 .saturating_add(candidate.selector.len() as u64)
6571 .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
6572 .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
6573 }
6574 }
6575 bytes
6576}
6577
6578#[cfg(test)]
6580pub(crate) fn parse_symbol_job(
6581 job: &SymbolParseJob,
6582 options: &SymbolBuildOptions,
6583 started_at: Instant,
6584) -> SymbolParseOutcome {
6585 let control = options
6586 .timeout
6587 .and_then(|timeout| started_at.checked_add(timeout))
6588 .map_or_else(
6589 || IndexWorkControl::new(IndexCancellation::new(), None),
6590 |deadline| IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
6591 );
6592 parse_symbol_job_controlled(job, options, &control)
6593}
6594
6595fn parse_symbol_job_controlled(
6597 job: &SymbolParseJob,
6598 options: &SymbolBuildOptions,
6599 control: &IndexWorkControl,
6600) -> SymbolParseOutcome {
6601 if options.is_timed_out(control.started_at()) {
6602 return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
6603 stage: IndexWorkStage::SymbolParsing,
6604 });
6605 }
6606 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
6607 return SymbolParseOutcome::IndexWork(failure);
6608 }
6609 let bytes = match admit_symbol_job_bytes(job, options, control) {
6610 Ok(bytes) => bytes,
6611 Err(outcome) => return *outcome,
6612 };
6613 if document_format_for_path(&job.path, job.language.as_deref()).is_some() {
6614 return parse_document_symbol_job(job, &bytes, options, control);
6615 }
6616 let Ok(content) = String::from_utf8(bytes) else {
6617 return SymbolParseOutcome::BinaryOrNonUtf8 {
6618 path: job.path.clone(),
6619 };
6620 };
6621 parse_admitted_symbol_job(job, &content, None, options, control)
6622}
6623
6624fn admit_symbol_job_bytes(
6626 job: &SymbolParseJob,
6627 options: &SymbolBuildOptions,
6628 control: &IndexWorkControl,
6629) -> Result<Vec<u8>, Box<SymbolParseOutcome>> {
6630 let max_bytes = source_input_byte_limit(
6631 &job.path,
6632 job.language.as_deref(),
6633 None,
6634 options.max_bytes,
6635 IndexWorkStage::SymbolParsing,
6636 )
6637 .map_err(|failure| Box::new(SymbolParseOutcome::IndexWork(failure)))?;
6638 let bytes = match read_source_bytes_controlled(
6639 &job.native_path,
6640 max_bytes,
6641 IndexWorkStage::SymbolParsing,
6642 control,
6643 ) {
6644 Ok(bytes) => bytes,
6645 Err(SourceReadFailure::Io(source)) => {
6646 return Err(Box::new(SymbolParseOutcome::Io {
6647 path: job.native_path.clone(),
6648 source,
6649 }));
6650 }
6651 Err(SourceReadFailure::IndexWork(failure)) => {
6652 return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
6653 }
6654 Err(SourceReadFailure::LimitExceeded { observed }) => {
6655 return Err(Box::new(SymbolParseOutcome::IndexWork(
6656 IndexWorkFailure::resource_limit(
6657 IndexWorkStage::SymbolParsing,
6658 IndexWorkResource::SourceBytes,
6659 max_bytes,
6660 observed,
6661 ),
6662 )));
6663 }
6664 };
6665 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
6666 return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
6667 }
6668 if blake3::hash(&bytes).to_hex().as_str() != job.expected_content_hash {
6669 return Err(Box::new(SymbolParseOutcome::SourceChanged {
6670 path: job.path.clone(),
6671 }));
6672 }
6673 Ok(bytes)
6674}
6675
6676fn parse_document_symbol_job(
6678 job: &SymbolParseJob,
6679 bytes: &[u8],
6680 _options: &SymbolBuildOptions,
6681 control: &IndexWorkControl,
6682) -> SymbolParseOutcome {
6683 let facts = match extract_document_symbol_facts_controlled(
6684 bytes,
6685 &job.path,
6686 job.language.as_deref(),
6687 control,
6688 ) {
6689 Ok(facts) => facts,
6690 Err(error) => return document_parse_error_outcome(&job.path, error),
6691 };
6692 let summary = document_summary_from_facts(&facts);
6693 let graph = facts.symbol_graph(&job.path, job.language.as_deref());
6694 let purpose_suggestion = job
6695 .purpose_needs_suggestion
6696 .then(|| suggest_file_purpose(&job.path, &summary));
6697 SymbolParseOutcome::Parsed(SymbolParseSuccess {
6698 path: job.path.clone(),
6699 graph,
6700 markdown_facts: None,
6701 source_parser: ParserKind::Structural,
6702 summary,
6703 summary_is_structural: false,
6704 purpose_suggestion,
6705 })
6706}
6707
6708fn document_parse_error_outcome(path: &str, error: DocumentExtractionError) -> SymbolParseOutcome {
6710 match error {
6711 DocumentExtractionError::Work(failure) => SymbolParseOutcome::IndexWork(failure),
6712 DocumentExtractionError::ResourceLimit {
6713 limit,
6714 observed,
6715 maximum,
6716 } => {
6717 let resource = match limit {
6718 DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
6719 DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
6720 DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
6721 DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
6722 IndexWorkResource::Entries
6723 }
6724 DocumentLimit::InputBytes
6725 | DocumentLimit::CompressedBytes
6726 | DocumentLimit::ExpandedBytes
6727 | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
6728 };
6729 SymbolParseOutcome::IndexWork(IndexWorkFailure::resource_limit(
6730 IndexWorkStage::SymbolParsing,
6731 resource,
6732 maximum as u64,
6733 observed as u64,
6734 ))
6735 }
6736 other => SymbolParseOutcome::InvalidInput {
6737 path: path.to_owned(),
6738 message: other.to_string(),
6739 },
6740 }
6741}
6742
6743fn document_navigation_error(path: &str, error: DocumentExtractionError) -> CliError {
6745 match error {
6746 DocumentExtractionError::Work(failure) => failure.into(),
6747 DocumentExtractionError::ResourceLimit {
6748 limit,
6749 observed,
6750 maximum,
6751 } => {
6752 let resource = match limit {
6753 DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
6754 DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
6755 DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
6756 DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
6757 IndexWorkResource::Entries
6758 }
6759 DocumentLimit::InputBytes
6760 | DocumentLimit::CompressedBytes
6761 | DocumentLimit::ExpandedBytes
6762 | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
6763 };
6764 IndexWorkFailure::resource_limit(
6765 IndexWorkStage::TextIndex,
6766 resource,
6767 maximum as u64,
6768 observed as u64,
6769 )
6770 .into()
6771 }
6772 other => CliError::InvalidInput(format!("document extraction failed for {path}: {other}")),
6773 }
6774}
6775
6776fn parse_admitted_symbol_job(
6778 job: &SymbolParseJob,
6779 content: &str,
6780 source_parser: Option<ParserKind>,
6781 options: &SymbolBuildOptions,
6782 control: &IndexWorkControl,
6783) -> SymbolParseOutcome {
6784 if options.is_timed_out(control.started_at()) {
6785 return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
6786 stage: IndexWorkStage::SymbolParsing,
6787 });
6788 }
6789 let (observed_source_parser, graph, markdown_facts) = if job
6790 .language
6791 .as_deref()
6792 .and_then(language_capability)
6793 .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
6794 {
6795 let facts = match extract_markdown_facts_controlled(content, control) {
6796 Ok(facts) => facts,
6797 Err(failure) => return SymbolParseOutcome::IndexWork(failure),
6798 };
6799 let graph = facts.symbol_graph(&job.path, job.language.as_deref());
6800 (graph.parser, graph, Some(Box::new(facts)))
6801 } else {
6802 let (parser, graph) = match extract_symbol_graph_with_source_controlled(
6803 &job.path,
6804 job.language.as_deref(),
6805 content,
6806 control,
6807 ) {
6808 Ok(graph) => graph,
6809 Err(failure) => return SymbolParseOutcome::IndexWork(failure),
6810 };
6811 (parser, graph, None)
6812 };
6813 let source_parser = source_parser.unwrap_or(observed_source_parser);
6814 let structural_summary = if let Some(facts) = &markdown_facts {
6815 markdown_summary_from_facts(
6816 facts,
6817 content
6818 .lines()
6819 .filter(|line| !line.trim().is_empty())
6820 .count(),
6821 )
6822 } else if graph.symbols.is_empty() {
6823 structural_summary_for_path(&job.path, job.language.as_deref(), content)
6824 } else {
6825 None
6826 };
6827 let summary_is_structural = structural_summary.is_some();
6828 let summary = structural_summary
6829 .unwrap_or_else(|| summarize_symbol_graph(&graph, job.fallback_summary.as_deref()));
6830 let purpose_suggestion = job
6831 .purpose_needs_suggestion
6832 .then(|| suggest_file_purpose(&job.path, &summary));
6833 SymbolParseOutcome::Parsed(SymbolParseSuccess {
6834 path: job.path.clone(),
6835 graph,
6836 markdown_facts,
6837 source_parser,
6838 summary,
6839 summary_is_structural,
6840 purpose_suggestion,
6841 })
6842}
6843
6844pub(crate) fn empty_symbol_build_report() -> SymbolBuildReport {
6846 SymbolBuildReport {
6847 candidates: 0,
6848 parsed: 0,
6849 unchanged: 0,
6850 too_large: 0,
6851 binary_or_non_utf8: 0,
6852 timed_out: 0,
6853 max_workers: 0,
6854 timeout_seconds: None,
6855 symbols: 0,
6856 relations: 0,
6857 summaries: 0,
6858 purpose_suggestions: 0,
6859 }
6860}
6861
6862fn empty_text_index_report(options: TextIndexOptions) -> TextIndexReport {
6864 TextIndexReport {
6865 candidates: 0,
6866 indexed: 0,
6867 binary_or_non_utf8: 0,
6868 too_large: 0,
6869 skipped: 0,
6870 max_bytes: options.max_bytes,
6871 bytes: 0,
6872 }
6873}
6874
6875pub(crate) fn summarize_symbol_graph(graph: &SymbolGraph, fallback: Option<&str>) -> String {
6877 if graph.symbols.is_empty() {
6878 if let Some(fallback) = fallback.filter(|summary| !is_scanner_fallback_summary(summary)) {
6879 return fallback.to_string();
6880 }
6881 let language = observed_language_label(graph.language.as_deref());
6882 if observed_content_noun(graph.language.as_deref()) == "document" {
6883 return format!("{language} document with no headings found.");
6884 }
6885 return format!("{language} source file with no declarations found.");
6886 }
6887 let language = observed_language_label(graph.language.as_deref());
6888 let primary_names = primary_symbol_names(graph, 4);
6889 let primary_kinds = primary_symbol_kinds(graph);
6890 let imports = relation_targets(graph, RelationKind::Imports, 2);
6891 let dependencies = relation_targets(graph, RelationKind::DependsOn, 3);
6892 if !dependencies.is_empty() {
6893 let subject = observed_manifest_subject(&language);
6894 return format!(
6895 "{subject} declaring {} and depending on {}.",
6896 primary_names.join(", "),
6897 dependencies.join(", ")
6898 );
6899 }
6900 if !imports.is_empty() {
6901 return format!(
6902 "{language} source defining {} {} with imports {}.",
6903 primary_kinds,
6904 primary_names.join(", "),
6905 imports.join(", ")
6906 );
6907 }
6908 if observed_content_noun(graph.language.as_deref()) == "document" {
6909 return format!(
6910 "{language} document with {} {}.",
6911 primary_kinds,
6912 primary_names.join(", ")
6913 );
6914 }
6915 format!(
6916 "{language} source defining {} {}.",
6917 primary_kinds,
6918 primary_names.join(", ")
6919 )
6920}
6921
6922fn observed_content_noun(language: Option<&str>) -> &'static str {
6924 if content_classification(language, true)
6925 == projectatlas_core::language::ContentClassification::Documentation
6926 {
6927 "document"
6928 } else {
6929 "source"
6930 }
6931}
6932
6933fn observed_language_label(language: Option<&str>) -> String {
6935 match language.unwrap_or("source") {
6936 "cargo-manifest" => "cargo manifest".to_string(),
6937 "cargo-lock" => "cargo lock".to_string(),
6938 "rust-build-script" => "rust build script".to_string(),
6939 "objective-c" => "Objective-C".to_string(),
6940 "csharp" => "C#".to_string(),
6941 "cpp" => "C++".to_string(),
6942 other => other.replace('-', " "),
6943 }
6944}
6945
6946fn observed_manifest_subject(language: &str) -> String {
6948 if language.contains("manifest") {
6949 language.to_string()
6950 } else {
6951 format!("{language} manifest")
6952 }
6953}
6954
6955pub(crate) fn primary_symbol_kinds(graph: &SymbolGraph) -> String {
6957 let mut function_like = 0_usize;
6958 let mut type_like = 0_usize;
6959 let mut manifest_like = 0_usize;
6960 let mut value_like = 0_usize;
6961 let mut heading_like = 0_usize;
6962 for symbol in &graph.symbols {
6963 match symbol.kind {
6964 SymbolKind::Function | SymbolKind::Method => function_like += 1,
6965 SymbolKind::Class
6966 | SymbolKind::Struct
6967 | SymbolKind::Enum
6968 | SymbolKind::Trait
6969 | SymbolKind::Interface
6970 | SymbolKind::Type => type_like += 1,
6971 SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Dependency => {
6972 manifest_like += 1;
6973 }
6974 SymbolKind::Value => value_like += 1,
6975 SymbolKind::Heading => heading_like += 1,
6976 SymbolKind::Module | SymbolKind::Import | SymbolKind::Unknown => {}
6977 }
6978 }
6979 if manifest_like > 0 && function_like == 0 && type_like == 0 {
6980 return "manifest entries".to_string();
6981 }
6982 if value_like > 0 && function_like == 0 && type_like == 0 {
6983 return value_only_symbol_kind_label(graph, value_like);
6984 }
6985 if heading_like > 0 && function_like == 0 && type_like == 0 {
6986 return if heading_like == 1 {
6987 "heading".to_string()
6988 } else {
6989 "headings".to_string()
6990 };
6991 }
6992 match (type_like, function_like) {
6993 (0, 0) => "symbols".to_string(),
6994 (0, 1) => "function".to_string(),
6995 (0, _) => "functions".to_string(),
6996 (1, 0) => "type".to_string(),
6997 (_, 0) => "types".to_string(),
6998 (1, 1) => "type and function".to_string(),
6999 (1, _) => "type and functions".to_string(),
7000 (_, 1) => "types and function".to_string(),
7001 (_, _) => "types and functions".to_string(),
7002 }
7003}
7004
7005pub(crate) fn value_only_symbol_kind_label(graph: &SymbolGraph, count: usize) -> String {
7007 let language = graph.language.as_deref().unwrap_or_default();
7008 let binding_language = matches!(
7009 language,
7010 "javascript" | "typescript" | "tsx" | "vue" | "svelte"
7011 ) || graph
7012 .symbols
7013 .iter()
7014 .any(|symbol| symbol.detail.as_deref() == Some("fallback-composition-binding"));
7015 let singular = if binding_language { "binding" } else { "value" };
7016 let plural = if binding_language {
7017 "bindings"
7018 } else {
7019 "values"
7020 };
7021 if count == 1 {
7022 singular.to_string()
7023 } else {
7024 plural.to_string()
7025 }
7026}
7027
7028pub(crate) fn primary_symbol_names(graph: &SymbolGraph, limit: usize) -> Vec<String> {
7030 let has_primary_definitions = graph.symbols.iter().any(|symbol| {
7031 matches!(
7032 symbol.kind,
7033 SymbolKind::Function
7034 | SymbolKind::Method
7035 | SymbolKind::Class
7036 | SymbolKind::Struct
7037 | SymbolKind::Enum
7038 | SymbolKind::Trait
7039 | SymbolKind::Interface
7040 | SymbolKind::Type
7041 )
7042 });
7043 let mut names = graph
7044 .symbols
7045 .iter()
7046 .filter(|symbol| {
7047 if has_primary_definitions && symbol.kind == SymbolKind::Value {
7048 return false;
7049 }
7050 !matches!(
7051 symbol.kind,
7052 SymbolKind::Import
7053 | SymbolKind::Dependency
7054 | SymbolKind::Module
7055 | SymbolKind::Unknown
7056 )
7057 })
7058 .map(|symbol| symbol.name.clone())
7059 .collect::<Vec<_>>();
7060 if names.is_empty() {
7061 names = graph
7062 .symbols
7063 .iter()
7064 .map(|symbol| symbol.name.clone())
7065 .collect::<Vec<_>>();
7066 }
7067 names.sort();
7068 names.dedup();
7069 names.truncate(limit);
7070 if names.is_empty() {
7071 vec!["indexed symbols".to_string()]
7072 } else {
7073 names
7074 }
7075}
7076
7077pub(crate) fn relation_targets(
7079 graph: &SymbolGraph,
7080 kind: RelationKind,
7081 limit: usize,
7082) -> Vec<String> {
7083 let mut targets = graph
7084 .relations
7085 .iter()
7086 .filter(|relation| relation.kind == kind)
7087 .map(|relation| relation.target_name.clone())
7088 .collect::<Vec<_>>();
7089 targets.sort();
7090 targets.dedup();
7091 targets.truncate(limit);
7092 targets
7093}
7094
7095pub(crate) fn suggest_file_purpose(path: &str, summary: &str) -> String {
7097 let subject = path_context_subject(path);
7098 if let Some(text) = summary
7099 .strip_prefix("pdf document text: ")
7100 .or_else(|| summary.strip_prefix("docx document text: "))
7101 {
7102 format!("Document {text}")
7103 } else if summary.contains("dataset manifest") {
7104 if let Some(datasets) = summary_between(summary, " including ", " and keys") {
7105 format!("Define the {subject} dataset manifest for {datasets}.")
7106 } else {
7107 format!("Define the {subject} dataset manifest.")
7108 }
7109 } else if let Some(workflow) = summary_between(summary, "yaml workflow ", " triggered") {
7110 format!("Define the {workflow} workflow.")
7111 } else if summary.contains("manifest") {
7112 if let Some(package) = summary_between(summary, " manifest for ", " with ") {
7113 format!("Define the {package} manifest.")
7114 } else {
7115 format!("Define the {subject} manifest.")
7116 }
7117 } else if let Some(title) = summary_between(summary, "document titled ", " with ") {
7118 format!("Document {title}.")
7119 } else if summary.contains("stylesheet") {
7120 format!("Style the {subject} stylesheet.")
7121 } else if summary.contains("config") {
7122 format!("Configure the {subject}.")
7123 } else if is_gradle_build_script(path) {
7124 if let Some(declarations) = summary_primary_declarations(summary) {
7125 format!("Define Gradle build tasks around {declarations}.")
7126 } else {
7127 "Configure the Gradle build.".to_string()
7128 }
7129 } else if let Some(declarations) = summary_primary_declarations(summary) {
7130 format!("Implement the {subject} source around {declarations}.")
7131 } else if summary.contains("source") {
7132 format!("Implement the {subject} source.")
7133 } else {
7134 format!("Implement the {subject}.")
7135 }
7136}
7137
7138fn is_gradle_build_script(path: &str) -> bool {
7140 let normalized = path.replace('\\', "/");
7141 normalized.ends_with("build.gradle") || normalized.ends_with("build.gradle.kts")
7142}
7143
7144fn path_context_subject(path: &str) -> String {
7146 let normalized = path.replace('\\', "/");
7147 let mut segments = normalized
7148 .split('/')
7149 .filter(|segment| !segment.is_empty() && *segment != ".")
7150 .collect::<Vec<_>>();
7151 let Some(file_name) = segments.pop() else {
7152 return "path".to_string();
7153 };
7154 let stem = file_name
7155 .rsplit_once('.')
7156 .map_or(file_name, |(stem, _)| stem);
7157 let stem_words = path_segment_words(stem);
7158 let parent_words = segments
7159 .iter()
7160 .rev()
7161 .find(|segment| !is_generic_context_segment(segment))
7162 .map(|segment| path_segment_words(segment));
7163 match parent_words {
7164 Some(parent) if !parent.is_empty() && parent != stem_words => {
7165 format!("{parent} {stem_words}")
7166 }
7167 _ => stem_words,
7168 }
7169}
7170
7171fn path_segment_words(segment: &str) -> String {
7173 let mut words = String::new();
7174 let mut previous_lowercase = false;
7175 for character in segment.chars() {
7176 if character == '-' || character == '_' || character == '.' {
7177 push_word_space(&mut words);
7178 previous_lowercase = false;
7179 continue;
7180 }
7181 if character.is_uppercase() && previous_lowercase {
7182 push_word_space(&mut words);
7183 }
7184 words.extend(character.to_lowercase());
7185 previous_lowercase = character.is_lowercase() || character.is_ascii_digit();
7186 }
7187 let words = words.trim();
7188 if words.is_empty() {
7189 "path".to_string()
7190 } else {
7191 words.to_string()
7192 }
7193}
7194
7195fn push_word_space(words: &mut String) {
7197 if !words.ends_with(' ') && !words.is_empty() {
7198 words.push(' ');
7199 }
7200}
7201
7202fn is_generic_context_segment(segment: &str) -> bool {
7204 matches!(
7205 segment.to_ascii_lowercase().as_str(),
7206 "src"
7207 | "source"
7208 | "sources"
7209 | "app"
7210 | "apps"
7211 | "lib"
7212 | "libs"
7213 | "crate"
7214 | "crates"
7215 | "package"
7216 | "packages"
7217 | "test"
7218 | "tests"
7219 | "spec"
7220 | "specs"
7221 | "fixture"
7222 | "fixtures"
7223 | "example"
7224 | "examples"
7225 | "script"
7226 | "scripts"
7227 )
7228}
7229
7230fn summary_primary_declarations(summary: &str) -> Option<String> {
7232 let after_marker = summary
7233 .split_once(" source defining ")
7234 .map(|(_, value)| value)
7235 .or_else(|| summary.split_once(" declaring ").map(|(_, value)| value))?;
7236 let declaration_clause = trim_summary_clause(after_marker);
7237 let names = strip_declaration_kind_prefix(declaration_clause)
7238 .split(',')
7239 .map(str::trim)
7240 .filter(|name| !name.is_empty())
7241 .take(3)
7242 .map(ToOwned::to_owned)
7243 .collect::<Vec<_>>();
7244 if names.is_empty() {
7245 None
7246 } else {
7247 Some(join_human_names(&names))
7248 }
7249}
7250
7251fn trim_summary_clause(value: &str) -> &str {
7253 value
7254 .split(" with imports ")
7255 .next()
7256 .unwrap_or(value)
7257 .split(" and depending on ")
7258 .next()
7259 .unwrap_or(value)
7260 .trim_end_matches('.')
7261 .trim()
7262}
7263
7264fn strip_declaration_kind_prefix(value: &str) -> &str {
7266 const PREFIXES: &[&str] = &[
7267 "types and functions ",
7268 "type and functions ",
7269 "types and function ",
7270 "type and function ",
7271 "manifest entries ",
7272 "functions ",
7273 "function ",
7274 "types ",
7275 "type ",
7276 "bindings ",
7277 "binding ",
7278 "values ",
7279 "value ",
7280 "symbols ",
7281 ];
7282 PREFIXES
7283 .iter()
7284 .find_map(|prefix| value.strip_prefix(prefix))
7285 .unwrap_or(value)
7286}
7287
7288fn join_human_names(names: &[String]) -> String {
7290 match names {
7291 [] => String::new(),
7292 [one] => one.clone(),
7293 [first, second] => format!("{first} and {second}"),
7294 [first, second, third, ..] => format!("{first}, {second}, and {third}"),
7295 }
7296}
7297
7298fn summary_between<'a>(summary: &'a str, start: &str, end: &str) -> Option<&'a str> {
7300 let after_start = summary.split_once(start)?.1;
7301 let value = after_start.split_once(end)?.0.trim();
7302 (!value.is_empty()).then_some(value)
7303}
7304
7305pub(crate) fn is_symbol_candidate(path: &str, language: Option<&str>) -> bool {
7307 let Some(language) = language else {
7308 return path.ends_with("Cargo.toml")
7309 || path.ends_with("Cargo.lock")
7310 || Path::new(path)
7311 .extension()
7312 .and_then(|extension| extension.to_str())
7313 .is_some_and(|extension| {
7314 ["vue", "ps1", "psm1", "psd1"]
7315 .iter()
7316 .any(|expected| extension.eq_ignore_ascii_case(expected))
7317 });
7318 };
7319 language_capability(language)
7320 .is_none_or(|capability| capability.symbol_parser != SymbolParserOwner::Unavailable)
7321}
7322
7323fn is_symbol_candidate_for_admission(
7325 path: &str,
7326 language: Option<&str>,
7327 admit_optional_languages: bool,
7328) -> bool {
7329 if !admit_optional_languages
7330 && language
7331 .and_then(language_capability)
7332 .is_some_and(|capability| capability.optional_pack.is_some())
7333 {
7334 return false;
7335 }
7336 is_symbol_candidate(path, language)
7337}
7338
7339fn clear_skipped_symbol_index(
7341 store: &AtlasStore,
7342 path: &str,
7343 language: Option<&str>,
7344) -> Result<(), CliError> {
7345 if is_structural_summary_candidate(path, language) {
7346 store.clear_symbol_graph_for_path(path)?;
7347 } else {
7348 store.clear_source_index_for_path(path)?;
7349 }
7350 Ok(())
7351}
7352
7353pub(crate) fn validated_file_key(file: &Path) -> Result<String, CliError> {
7355 validated_repo_file_key(file).map_err(|source| CliError::InvalidInput(source.to_string()))
7356}
7357
7358pub(crate) fn normalized_folder_filter(folder: &str) -> Result<String, CliError> {
7360 let trimmed = folder.trim().trim_end_matches(['/', '\\']);
7361 if trimmed.is_empty() || trimmed == "." {
7362 return Ok(".".to_string());
7363 }
7364 validated_file_key(Path::new(trimmed)).map_err(|_error| {
7365 CliError::InvalidInput(format!(
7366 "folder filter {folder:?} must be a project-relative path"
7367 ))
7368 })
7369}
7370
7371pub(crate) fn validated_indexed_file_key(
7373 store: &AtlasStore,
7374 file: &Path,
7375) -> Result<String, CliError> {
7376 let file_key = validated_file_key(file)?;
7377 let indexed = store
7378 .load_node_by_path(&file_key)?
7379 .ok_or_else(|| CliError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
7380 if indexed.node.kind != NodeKind::File {
7381 return Err(CliError::InvalidInput(format!(
7382 "path {file_key:?} is not an indexed file"
7383 )));
7384 }
7385 Ok(file_key)
7386}
7387
7388pub(crate) fn indexed_project_root(store: &AtlasStore) -> Result<PathBuf, CliError> {
7390 store
7391 .project_root_identity()?
7392 .map(CanonicalProjectRoot::into_path)
7393 .ok_or_else(|| {
7394 CliError::InvalidInput(
7395 "indexed project root is missing; run projectatlas scan <project-root> first"
7396 .to_string(),
7397 )
7398 })
7399}
7400
7401pub(crate) fn indexed_native_path(store: &AtlasStore, file_key: &str) -> Result<PathBuf, CliError> {
7403 Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
7404}
7405
7406pub(crate) fn read_indexed_file_content(
7408 store: &AtlasStore,
7409 file_key: &str,
7410) -> Result<String, CliError> {
7411 let native = indexed_native_path(store, file_key)?;
7412 let indexed = store.load_node_by_path(file_key)?.ok_or_else(|| {
7413 CliError::InvalidInput(format!("indexed file {file_key:?} was not found"))
7414 })?;
7415 let project_root = lossless_project_root_display(&indexed_project_root(store)?);
7416 let metadata = match fs::metadata(&native) {
7417 Ok(metadata) => metadata,
7418 Err(source) if source.kind() == io::ErrorKind::NotFound => {
7419 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7420 project_root,
7421 worktree: None,
7422 status: IndexReadStatus::RefreshRequired,
7423 reason: IndexRefreshReason::PathsChanged,
7424 scope: IndexRefreshScope::Full,
7425 changed: 1,
7426 added: 0,
7427 removed: 1,
7428 modified: 0,
7429 sample_paths: vec![file_key.to_string()],
7430 })));
7431 }
7432 Err(source) => {
7433 return Err(CliError::VerificationIncomplete(Box::new(
7434 IndexVerificationIncomplete {
7435 project_root,
7436 worktree: None,
7437 status: IndexReadStatus::VerificationIncomplete,
7438 reason: IndexVerificationReason::SourceInspectionFailed,
7439 scope: IndexRefreshScope::Full,
7440 message: format!("failed to read '{}': {source}", native.display()),
7441 },
7442 )));
7443 }
7444 };
7445 if indexed
7446 .node
7447 .size_bytes
7448 .is_some_and(|indexed_bytes| indexed_bytes != metadata.len())
7449 {
7450 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7451 project_root,
7452 worktree: None,
7453 status: IndexReadStatus::RefreshRequired,
7454 reason: IndexRefreshReason::SourceChanged,
7455 scope: IndexRefreshScope::Full,
7456 changed: 1,
7457 added: 0,
7458 removed: 0,
7459 modified: 1,
7460 sample_paths: vec![file_key.to_string()],
7461 })));
7462 }
7463 if metadata.len() > MAX_INDEXED_NAVIGATION_SOURCE_BYTES {
7464 return Err(CliError::VerificationIncomplete(Box::new(
7465 IndexVerificationIncomplete {
7466 project_root,
7467 worktree: None,
7468 status: IndexReadStatus::VerificationIncomplete,
7469 reason: IndexVerificationReason::SourceTooLarge,
7470 scope: IndexRefreshScope::Full,
7471 message: format!(
7472 "indexed file {file_key:?} contains {} bytes; bounded navigation reads admit at most {MAX_INDEXED_NAVIGATION_SOURCE_BYTES} bytes",
7473 metadata.len()
7474 ),
7475 },
7476 )));
7477 }
7478 let file = fs::File::open(&native).map_err(|source| {
7479 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7480 project_root: project_root.clone(),
7481 worktree: None,
7482 status: IndexReadStatus::VerificationIncomplete,
7483 reason: IndexVerificationReason::SourceInspectionFailed,
7484 scope: IndexRefreshScope::Full,
7485 message: format!("failed to open '{}': {source}", native.display()),
7486 }))
7487 })?;
7488 let mut bytes = Vec::with_capacity(metadata.len() as usize);
7489 file.take(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)
7490 .read_to_end(&mut bytes)
7491 .map_err(|source| {
7492 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7493 project_root: project_root.clone(),
7494 worktree: None,
7495 status: IndexReadStatus::VerificationIncomplete,
7496 reason: IndexVerificationReason::SourceInspectionFailed,
7497 scope: IndexRefreshScope::Full,
7498 message: format!("failed to read '{}': {source}", native.display()),
7499 }))
7500 })?;
7501 if bytes.len() as u64 != metadata.len()
7502 || bytes.len() as u64 > MAX_INDEXED_NAVIGATION_SOURCE_BYTES
7503 {
7504 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7505 project_root,
7506 worktree: None,
7507 status: IndexReadStatus::RefreshRequired,
7508 reason: IndexRefreshReason::SourceChanged,
7509 scope: IndexRefreshScope::Full,
7510 changed: 1,
7511 added: 0,
7512 removed: 0,
7513 modified: 1,
7514 sample_paths: vec![file_key.to_string()],
7515 })));
7516 }
7517 let current_hash = blake3::hash(&bytes).to_hex().to_string();
7518 if indexed.node.content_hash.as_deref() != Some(current_hash.as_str()) {
7519 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7520 project_root,
7521 worktree: None,
7522 status: IndexReadStatus::RefreshRequired,
7523 reason: IndexRefreshReason::SourceChanged,
7524 scope: IndexRefreshScope::Full,
7525 changed: 1,
7526 added: 0,
7527 removed: 0,
7528 modified: 1,
7529 sample_paths: vec![file_key.to_string()],
7530 })));
7531 }
7532 if document_format_for_path(file_key, indexed.node.language.as_deref()).is_some() {
7533 return extract_document_text_controlled(
7534 &bytes,
7535 file_key,
7536 indexed.node.language.as_deref(),
7537 &standalone_index_work_control(),
7538 )
7539 .map(|facts| facts.text)
7540 .map_err(|error| document_navigation_error(file_key, error));
7541 }
7542 String::from_utf8(bytes).map_err(|source| {
7543 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7544 project_root,
7545 worktree: None,
7546 status: IndexReadStatus::VerificationIncomplete,
7547 reason: IndexVerificationReason::SourceInspectionFailed,
7548 scope: IndexRefreshScope::Full,
7549 message: format!("indexed file {file_key:?} is not valid UTF-8: {source}"),
7550 }))
7551 })
7552}
7553
7554pub(crate) fn run_watch_loop(
7556 store: &mut AtlasStore,
7557 plan: &ScanRuntimePlan,
7558 once: bool,
7559 poll_seconds: u64,
7560 max_cycles: usize,
7561 symbol_options: &SymbolBuildOptions,
7562) -> Result<WatchReport, CliError> {
7563 if once {
7564 return run_single_watch_refresh(store, plan, symbol_options);
7565 }
7566 run_watch_with_polling_fallback(
7567 store,
7568 plan,
7569 poll_seconds,
7570 max_cycles,
7571 symbol_options,
7572 |store| run_notify_watch_loop(store, plan, poll_seconds, max_cycles, symbol_options),
7573 )
7574}
7575
7576fn run_watch_with_polling_fallback<F>(
7578 store: &mut AtlasStore,
7579 plan: &ScanRuntimePlan,
7580 poll_seconds: u64,
7581 max_cycles: usize,
7582 symbol_options: &SymbolBuildOptions,
7583 run_notify: F,
7584) -> Result<WatchReport, CliError>
7585where
7586 F: FnOnce(&mut AtlasStore) -> Result<WatchReport, CliError>,
7587{
7588 match run_notify(store) {
7589 Ok(report) => Ok(report),
7590 Err(error @ CliError::RefreshRequired(_)) => Err(error),
7591 Err(error) => run_polling_watch_loop(
7592 store,
7593 plan,
7594 poll_seconds,
7595 max_cycles,
7596 symbol_options,
7597 Some(error.to_string()),
7598 ),
7599 }
7600}
7601
7602pub(crate) fn run_single_watch_refresh(
7604 store: &mut AtlasStore,
7605 plan: &ScanRuntimePlan,
7606 symbol_options: &SymbolBuildOptions,
7607) -> Result<WatchReport, CliError> {
7608 let control = index_work_control(symbol_options);
7609 run_single_watch_refresh_controlled(store, plan, symbol_options, &control)
7610}
7611
7612pub(crate) fn run_single_watch_refresh_controlled(
7614 store: &mut AtlasStore,
7615 plan: &ScanRuntimePlan,
7616 symbol_options: &SymbolBuildOptions,
7617 control: &IndexWorkControl,
7618) -> Result<WatchReport, CliError> {
7619 let bounded_control = bounded_index_work_control(control);
7620 let control = &bounded_control;
7621 control.check(IndexWorkStage::RepositoryTraversal)?;
7622 let current_plan = plan.reload_controlled(control)?;
7623 let last_refresh = refresh_index_controlled(store, ¤t_plan, symbol_options, control)?;
7624 Ok(WatchReport {
7625 mode: WATCH_MODE_ONCE.to_string(),
7626 cycles: 1,
7627 once: true,
7628 fallback_reason: None,
7629 text_index: last_refresh.text_index,
7630 structural_summaries: last_refresh.structural_summaries,
7631 last_symbols: last_refresh.symbols,
7632 })
7633}
7634
7635pub(crate) fn run_notify_watch_loop(
7637 store: &mut AtlasStore,
7638 plan: &ScanRuntimePlan,
7639 poll_seconds: u64,
7640 max_cycles: usize,
7641 symbol_options: &SymbolBuildOptions,
7642) -> Result<WatchReport, CliError> {
7643 let mut current_plan = plan.reload()?;
7644 let watch_root = current_plan
7645 .root
7646 .canonicalize()
7647 .map_err(|source| CliError::Io {
7648 path: current_plan.root.clone(),
7649 source,
7650 })?;
7651 let (sender, receiver) = mpsc::sync_channel(WATCH_EVENT_QUEUE_CAPACITY);
7652 let continuity_lost = Arc::new(AtomicBool::new(false));
7653 let callback_continuity_lost = Arc::clone(&continuity_lost);
7654 let mut watcher = RecommendedWatcher::new(
7655 move |result: notify::Result<Event>| {
7656 match sender.try_send(result) {
7657 Ok(()) => {}
7658 Err(TrySendError::Full(_result)) => {
7659 callback_continuity_lost.store(true, Ordering::Release);
7660 }
7661 Err(TrySendError::Disconnected(_result)) => {
7662 }
7664 }
7665 },
7666 Config::default(),
7667 )
7668 .map_err(|source| CliError::Watcher(source.to_string()))?;
7669 watcher
7670 .watch(&watch_root, RecursiveMode::Recursive)
7671 .map_err(|source| CliError::Watcher(source.to_string()))?;
7672 let debounce = Duration::from_secs(poll_seconds.max(1));
7673 let mut cycles = 0;
7674 let mut last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
7675 cycles += 1;
7676 while max_cycles == 0 || cycles < max_cycles {
7677 let changes = wait_for_index_event_with_continuity(
7678 &receiver,
7679 &watch_root,
7680 debounce,
7681 ¤t_plan.scan_options,
7682 &continuity_lost,
7683 )?;
7684 if changes.has_changes() {
7685 current_plan = plan.reload()?;
7686 last_refresh =
7687 refresh_index_for_changes(store, ¤t_plan, &changes, symbol_options)?;
7688 cycles += 1;
7689 }
7690 }
7691 Ok(WatchReport {
7692 mode: WATCH_MODE_NOTIFY.to_string(),
7693 cycles,
7694 once: false,
7695 fallback_reason: None,
7696 text_index: last_refresh.text_index,
7697 structural_summaries: last_refresh.structural_summaries,
7698 last_symbols: last_refresh.symbols,
7699 })
7700}
7701
7702fn wait_for_index_event_with_continuity(
7704 receiver: &mpsc::Receiver<notify::Result<Event>>,
7705 root: &Path,
7706 debounce: Duration,
7707 scan_options: &ScanOptions,
7708 continuity_lost: &AtomicBool,
7709) -> Result<WatchChangeSet, CliError> {
7710 let mut changes = notify_result_changes(
7711 root,
7712 scan_options,
7713 receiver.recv().map_err(|source| {
7714 CliError::Watcher(format!("watch event channel disconnected: {source}"))
7715 })?,
7716 )?;
7717 loop {
7718 match receiver.recv_timeout(debounce) {
7719 Ok(result) => {
7720 changes.merge(notify_result_changes(root, scan_options, result)?);
7721 }
7722 Err(RecvTimeoutError::Timeout) => break,
7723 Err(RecvTimeoutError::Disconnected) => {
7724 return Err(CliError::Watcher(
7725 "watch event channel disconnected".to_string(),
7726 ));
7727 }
7728 }
7729 }
7730 if continuity_lost.swap(false, Ordering::AcqRel) {
7731 changes.requires_full_scan = true;
7732 }
7733 Ok(changes)
7734}
7735
7736pub(crate) fn notify_result_changes(
7738 root: &Path,
7739 scan_options: &ScanOptions,
7740 result: notify::Result<Event>,
7741) -> Result<WatchChangeSet, CliError> {
7742 let event = result.map_err(|source| CliError::Watcher(source.to_string()))?;
7743 Ok(notify_event_changes(root, scan_options, &event))
7744}
7745
7746pub(crate) fn notify_event_changes(
7748 root: &Path,
7749 scan_options: &ScanOptions,
7750 event: &Event,
7751) -> WatchChangeSet {
7752 if !event_kind_affects_index(event.kind) {
7753 return WatchChangeSet::default();
7754 }
7755 let mut changes = WatchChangeSet {
7756 requires_full_scan: event.need_rescan(),
7757 paths: HashSet::new(),
7758 document_paths: HashSet::new(),
7759 };
7760 for path in &event.paths {
7761 let candidate = absolute_watch_path(root, path);
7762 if safe_watch_relative_path(root, &candidate).is_some() {
7763 changes.document_paths.insert(candidate.clone());
7764 }
7765 if watch_path_requires_full_scan(root, &candidate) {
7766 changes.requires_full_scan = true;
7767 changes.paths.insert(candidate);
7768 continue;
7769 }
7770 let Some(index_path) = normalized_watch_index_path(root, path, scan_options) else {
7771 continue;
7772 };
7773 if matches!(
7774 event.kind,
7775 EventKind::Modify(notify::event::ModifyKind::Name(_))
7776 ) || watch_path_requires_full_scan(root, &index_path)
7777 {
7778 changes.requires_full_scan = true;
7779 }
7780 changes.paths.insert(index_path);
7781 }
7782 changes
7783}
7784
7785pub(crate) fn event_kind_affects_index(kind: EventKind) -> bool {
7787 !matches!(kind, EventKind::Access(_))
7788}
7789
7790#[cfg(test)]
7792pub(crate) fn watch_path_affects_index(
7793 root: &Path,
7794 path: &Path,
7795 scan_options: &ScanOptions,
7796) -> bool {
7797 normalized_watch_index_path(root, path, scan_options).is_some()
7798}
7799
7800fn normalized_watch_index_path(
7802 root: &Path,
7803 path: &Path,
7804 scan_options: &ScanOptions,
7805) -> Option<PathBuf> {
7806 let candidate = absolute_watch_path(root, path);
7807 let relative = safe_watch_relative_path(root, &candidate)?;
7808 if relative == "." {
7809 return Some(root.to_path_buf());
7810 }
7811 let policy_path = if candidate.strip_prefix(root).is_ok() {
7812 candidate.clone()
7813 } else {
7814 match candidate.try_exists() {
7815 Ok(true) => candidate.clone(),
7816 Ok(false) => root.join(repo_path_to_native(&relative)),
7817 Err(_) => return None,
7818 }
7819 };
7820 let Ok(gitignore_ignored) = gitignore_excludes_path(root, &policy_path) else {
7822 return None;
7823 };
7824 if gitignore_ignored {
7825 return None;
7826 }
7827 if relative.split('/').any(|component| component == ".purpose")
7828 || scan_options.excludes_relative_path(&relative)
7829 {
7830 return None;
7831 }
7832 Some(candidate)
7833}
7834
7835fn safe_watch_relative_path(root: &Path, candidate: &Path) -> Option<String> {
7837 let relative = normalize_repo_path(root, candidate)
7838 .ok()
7839 .or_else(|| native_display_relative_path(root, candidate))?;
7840 valid_watch_relative_path(relative)
7841}
7842
7843fn native_display_relative_path(root: &Path, candidate: &Path) -> Option<String> {
7845 let root = normalize_native_path_display_str(root.to_str()?);
7846 let candidate = normalize_native_path_display_str(candidate.to_str()?);
7847 let root = if root == "/" {
7848 root.as_str()
7849 } else {
7850 root.trim_end_matches('/')
7851 };
7852 if candidate == root || cfg!(windows) && candidate.eq_ignore_ascii_case(root) {
7853 return Some(".".to_string());
7854 }
7855 let prefix = if root == "/" {
7856 "/".to_string()
7857 } else {
7858 format!("{root}/")
7859 };
7860 if let Some(relative) = candidate.strip_prefix(&prefix) {
7861 return Some(relative.to_string());
7862 }
7863 #[cfg(windows)]
7864 {
7865 let prefix_candidate = candidate.get(..prefix.len())?;
7866 if prefix_candidate.eq_ignore_ascii_case(&prefix) {
7867 return candidate.get(prefix.len()..).map(ToOwned::to_owned);
7868 }
7869 }
7870 None
7871}
7872
7873fn valid_watch_relative_path(relative: String) -> Option<String> {
7875 if relative == "." {
7876 return Some(relative);
7877 }
7878 if relative
7879 .split('/')
7880 .any(|component| component.is_empty() || component == "." || component == "..")
7881 {
7882 return None;
7883 }
7884 Some(relative)
7885}
7886
7887pub(crate) fn absolute_watch_path(root: &Path, path: &Path) -> PathBuf {
7889 if path.is_absolute() {
7890 path.to_path_buf()
7891 } else {
7892 root.join(path)
7893 }
7894}
7895
7896pub(crate) fn watch_path_requires_full_scan(root: &Path, path: &Path) -> bool {
7898 let Some(relative) = safe_watch_relative_path(root, path) else {
7899 return false;
7900 };
7901 if relative == "." {
7902 return true;
7903 }
7904 path.is_dir()
7905 || matches!(relative.rsplit('/').next(), Some(".gitignore" | ".ignore"))
7906 || relative == ".git"
7907 || relative.ends_with("/.git")
7908 || relative == ".git/info/exclude"
7909 || relative.ends_with("/.git/info/exclude")
7910 || matches!(
7911 relative.rsplit('/').next(),
7912 Some("tsconfig.json" | "jsconfig.json")
7913 )
7914 || index_policy_path(relative.as_str())
7915}
7916
7917fn index_policy_path(relative: &str) -> bool {
7919 CORE_INDEX_POLICY_PATHS.contains(&relative)
7920 || cfg!(feature = "optional-parser-supervisor") && {
7921 #[cfg(feature = "optional-parser-supervisor")]
7922 {
7923 relative == OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH
7924 }
7925 #[cfg(not(feature = "optional-parser-supervisor"))]
7926 {
7927 false
7928 }
7929 }
7930}
7931
7932pub(crate) fn run_polling_watch_loop(
7934 store: &mut AtlasStore,
7935 plan: &ScanRuntimePlan,
7936 poll_seconds: u64,
7937 max_cycles: usize,
7938 symbol_options: &SymbolBuildOptions,
7939 fallback_reason: Option<String>,
7940) -> Result<WatchReport, CliError> {
7941 let mut cycles = 0;
7942 let mut current_plan = plan.reload()?;
7943 let mut last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
7944 cycles += 1;
7945 while max_cycles == 0 || cycles < max_cycles {
7946 thread::sleep(Duration::from_secs(poll_seconds.max(1)));
7947 current_plan = plan.reload()?;
7948 last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
7949 cycles += 1;
7950 }
7951 Ok(WatchReport {
7952 mode: WATCH_MODE_POLLING.to_string(),
7953 cycles,
7954 once: false,
7955 fallback_reason,
7956 text_index: last_refresh.text_index,
7957 structural_summaries: last_refresh.structural_summaries,
7958 last_symbols: last_refresh.symbols,
7959 })
7960}
7961
7962pub(crate) struct IndexRefreshReport {
7964 pub(crate) text_index: TextIndexReport,
7966 pub(crate) structural_summaries: StructuralSummaryReport,
7968 symbols: SymbolBuildReport,
7970}
7971
7972pub(crate) fn refresh_index(
7974 store: &mut AtlasStore,
7975 plan: &ScanRuntimePlan,
7976 symbol_options: &SymbolBuildOptions,
7977) -> Result<IndexRefreshReport, CliError> {
7978 let control = index_work_control(symbol_options);
7979 refresh_index_controlled(store, plan, symbol_options, &control)
7980}
7981
7982pub(crate) fn refresh_index_controlled(
7984 store: &mut AtlasStore,
7985 plan: &ScanRuntimePlan,
7986 symbol_options: &SymbolBuildOptions,
7987 control: &IndexWorkControl,
7988) -> Result<IndexRefreshReport, CliError> {
7989 let bounded_control = bounded_index_work_control(control);
7990 let control = &bounded_control;
7991 let reuse_unchanged_symbols = publication_contract_matches(store, plan)?;
7992 if reuse_unchanged_symbols
7993 && detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?
7994 .delta
7995 .is_none()
7996 {
7997 graph_projection::cleanup_abandoned_repository_graph_staging(store, &plan.root, control)?;
7998 return Ok(empty_index_refresh_report(plan.text_options));
7999 }
8000 control.check(IndexWorkStage::Publication)?;
8001 store.probe_index_publication_writer()?;
8002 let batch = stage_full_index_publication(
8003 store,
8004 plan,
8005 symbol_options,
8006 reuse_unchanged_symbols,
8007 false,
8008 control,
8009 )?;
8010 revalidate_staged_publication_inputs_controlled(
8011 plan,
8012 batch.nodes.expected_nodes(),
8013 None,
8014 control,
8015 )?;
8016 if staged_full_refresh_is_unchanged(store, &batch)? {
8017 return Ok(empty_index_refresh_report(plan.text_options));
8018 }
8019 let outcome = publish_index_batch(store, batch, control)?;
8020 Ok(IndexRefreshReport {
8021 text_index: outcome.text_index,
8022 structural_summaries: outcome.structural_summaries,
8023 symbols: outcome.symbols,
8024 })
8025}
8026
8027fn staged_full_refresh_is_unchanged(
8029 store: &AtlasStore,
8030 batch: &IndexPublicationBatch,
8031) -> Result<bool, CliError> {
8032 let NodePublicationBatch::Full { nodes: expected } = &batch.nodes else {
8033 return Ok(false);
8034 };
8035 if batch.purpose_import.is_some() {
8036 return Ok(false);
8037 }
8038 let Some(publication) = store.index_publication()? else {
8039 return Ok(false);
8040 };
8041 if publication.state != IndexPublicationState::Complete
8042 || publication.generation != batch.base_generation
8043 || publication.contract_fingerprint.as_deref() != Some(batch.contract_fingerprint.as_str())
8044 {
8045 return Ok(false);
8046 }
8047 let current = store
8048 .load_nodes()?
8049 .into_iter()
8050 .map(|indexed| indexed.node)
8051 .collect::<Vec<_>>();
8052 Ok(current.len() == expected.len()
8053 && current
8054 .iter()
8055 .zip(expected)
8056 .all(|(current, expected)| same_indexed_source(current, expected)))
8057}
8058
8059fn empty_index_refresh_report(text_options: TextIndexOptions) -> IndexRefreshReport {
8061 IndexRefreshReport {
8062 text_index: empty_text_index_report(text_options),
8063 structural_summaries: StructuralSummaryReport::default(),
8064 symbols: empty_symbol_build_report(),
8065 }
8066}
8067
8068pub(crate) fn refresh_index_for_changes(
8070 store: &mut AtlasStore,
8071 plan: &ScanRuntimePlan,
8072 changes: &WatchChangeSet,
8073 symbol_options: &SymbolBuildOptions,
8074) -> Result<IndexRefreshReport, CliError> {
8075 let control = index_work_control(symbol_options);
8076 refresh_index_for_changes_controlled(store, plan, changes, symbol_options, &control)
8077}
8078
8079pub(crate) fn refresh_index_for_changes_controlled(
8081 store: &mut AtlasStore,
8082 plan: &ScanRuntimePlan,
8083 changes: &WatchChangeSet,
8084 symbol_options: &SymbolBuildOptions,
8085 control: &IndexWorkControl,
8086) -> Result<IndexRefreshReport, CliError> {
8087 let bounded_control = bounded_index_work_control(control);
8088 let control = &bounded_control;
8089 control.check(IndexWorkStage::RepositoryTraversal)?;
8090 if changes.requires_full_scan || !publication_contract_matches(store, plan)? {
8091 return refresh_index_controlled(store, plan, symbol_options, control);
8092 }
8093 let changed_event_path_count = changes.paths.union(&changes.document_paths).count();
8094 if changed_event_path_count > MAX_INCREMENTAL_CHANGED_PATHS {
8095 return Err(IndexWorkFailure::resource_limit(
8096 IndexWorkStage::RepositoryTraversal,
8097 IndexWorkResource::Entries,
8098 MAX_INCREMENTAL_CHANGED_PATHS as u64,
8099 changed_event_path_count as u64,
8100 )
8101 .into());
8102 }
8103 let root = &plan.root;
8104 let base_generation = publication_base_generation(store)?;
8105 let baseline_nodes = store
8106 .load_nodes()?
8107 .into_iter()
8108 .map(|indexed| indexed.node)
8109 .collect::<Vec<_>>();
8110 let baseline_by_path = baseline_nodes
8111 .iter()
8112 .map(|node| (node.path.clone(), node))
8113 .collect::<HashMap<_, _>>();
8114 let mut nodes = Vec::new();
8115 let mut absent_paths = Vec::new();
8116 let mut source_bytes = 0_u64;
8117 let scan_policy = RootScanPolicy::discover(root, &plan.scan_options, control)
8118 .map_err(|source| source_inspection_error(root, source))?;
8119 let mut direct_document_paths = changes
8120 .paths
8121 .union(&changes.document_paths)
8122 .map(|path| normalized_deleted_path(root, path))
8123 .collect::<Result<Vec<_>, _>>()?
8124 .into_iter()
8125 .flatten()
8126 .collect::<BTreeSet<_>>();
8127 for path in sorted_watch_paths(&changes.paths) {
8128 control.check(IndexWorkStage::RepositoryTraversal)?;
8129 match path.try_exists() {
8130 Ok(true) => {
8131 let remaining_source_bytes =
8132 MAX_INCREMENTAL_SOURCE_BYTES.saturating_sub(source_bytes);
8133 if let Some(node) = scan_path_with_policy_controlled(
8134 &scan_policy,
8135 &path,
8136 ScanLimits::new(1, remaining_source_bytes, 1),
8137 control,
8138 )
8139 .map_err(|source| source_inspection_error(root, source))?
8140 {
8141 source_bytes = source_bytes
8142 .checked_add(node.size_bytes.unwrap_or_default())
8143 .ok_or_else(|| {
8144 IndexWorkFailure::resource_limit(
8145 IndexWorkStage::SourceMetadata,
8146 IndexWorkResource::SourceBytes,
8147 MAX_INCREMENTAL_SOURCE_BYTES,
8148 u64::MAX,
8149 )
8150 })?;
8151 if source_bytes > MAX_INCREMENTAL_SOURCE_BYTES {
8152 return Err(IndexWorkFailure::resource_limit(
8153 IndexWorkStage::SourceMetadata,
8154 IndexWorkResource::SourceBytes,
8155 MAX_INCREMENTAL_SOURCE_BYTES,
8156 source_bytes,
8157 )
8158 .into());
8159 }
8160 nodes.push(node);
8161 } else if let Some(path_key) = normalized_deleted_path(root, &path)? {
8162 absent_paths.push(path_key);
8163 }
8164 }
8165 Ok(false) => {
8166 if let Some(path_key) = normalized_deleted_path(root, &path)? {
8167 absent_paths.push(path_key);
8168 }
8169 }
8170 Err(source) => {
8171 return Err(CliError::VerificationIncomplete(Box::new(
8172 IndexVerificationIncomplete {
8173 project_root: lossless_project_root_display(root),
8174 worktree: None,
8175 status: IndexReadStatus::VerificationIncomplete,
8176 reason: IndexVerificationReason::SourceInspectionFailed,
8177 scope: IndexRefreshScope::Full,
8178 message: format!("failed to inspect '{}': {source}", path.display()),
8179 },
8180 )));
8181 }
8182 }
8183 }
8184 absent_paths.sort();
8185 absent_paths.dedup();
8186 let candidate_paths = nodes
8187 .iter()
8188 .map(|node| node.path.clone())
8189 .chain(absent_paths.iter().cloned())
8190 .collect::<HashSet<_>>();
8191 let mut sorted_candidate_paths = candidate_paths.into_iter().collect::<Vec<_>>();
8192 sorted_candidate_paths.sort();
8193 let existing_nodes = sorted_candidate_paths
8194 .iter()
8195 .filter_map(|path| {
8196 baseline_by_path
8197 .get(path)
8198 .map(|node| (path.clone(), (*node).clone()))
8199 })
8200 .collect::<HashMap<_, _>>();
8201 if absent_paths.iter().any(|path| {
8202 existing_nodes
8203 .get(path)
8204 .is_some_and(|node| node.kind == NodeKind::Folder)
8205 }) {
8206 return refresh_index_controlled(store, plan, symbol_options, control);
8207 }
8208 graph_projection::cleanup_abandoned_repository_graph_staging(store, root, control)?;
8209 direct_document_paths.extend(
8210 nodes
8211 .iter()
8212 .map(|node| node.path.clone())
8213 .chain(absent_paths.iter().cloned()),
8214 );
8215 nodes.retain(|node| {
8216 existing_nodes
8217 .get(&node.path)
8218 .is_none_or(|indexed| !same_indexed_source(node, indexed))
8219 });
8220 absent_paths.retain(|path| existing_nodes.contains_key(path));
8221 let changed_paths = nodes
8222 .iter()
8223 .map(|node| node.path.clone())
8224 .chain(absent_paths.iter().cloned())
8225 .collect::<HashSet<_>>();
8226 direct_document_paths
8227 .retain(|path| changed_paths.contains(path) || !baseline_by_path.contains_key(path));
8228 if nodes.is_empty() && absent_paths.is_empty() && direct_document_paths.is_empty() {
8229 revalidate_staged_publication_inputs_controlled(plan, &baseline_nodes, None, control)?;
8230 return Ok(empty_index_refresh_report(plan.text_options));
8231 }
8232 control.check(IndexWorkStage::Publication)?;
8233 store.probe_index_publication_writer()?;
8234 drop(existing_nodes);
8235 drop(baseline_by_path);
8236 let previous_hashes = indexed_file_hashes_for_paths(store, &changed_paths)?;
8237 let mut text_paths = changed_paths.iter().cloned().collect::<Vec<_>>();
8238 text_paths.sort();
8239 let text =
8240 stage_text_index_for_changed_paths_controlled(root, &nodes, plan.text_options, control)?;
8241 let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
8242 let protected_purpose_paths = protected_purpose_paths(&nodes, None);
8243 let target_paths = nodes
8244 .iter()
8245 .filter(|node| node.kind == NodeKind::File)
8246 .map(|node| node.path.clone())
8247 .collect::<HashSet<_>>();
8248 let expected_nodes = expected_nodes_after_incremental(baseline_nodes, &nodes, &absent_paths);
8249 let contract_fingerprint = plan.publication_contract_fingerprint();
8250 let retained_before_symbols = staged_publication_identity_bytes(root, &contract_fingerprint)
8251 .saturating_add(staged_string_bytes(&text_paths))
8252 .saturating_add(staged_string_bytes(&absent_paths))
8253 .saturating_add(staged_node_bytes(&expected_nodes))
8254 .saturating_add(staged_node_bytes(&nodes))
8255 .saturating_add(staged_text_bytes(&text))
8256 .saturating_add(staged_file_content_classification_bytes(
8257 &content_classifications,
8258 ));
8259 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
8260 let symbols = stage_symbols_for_nodes_with_limits(
8261 store,
8262 root,
8263 #[cfg(feature = "optional-parser-supervisor")]
8264 &plan.optional_parser_selection,
8265 &nodes,
8266 symbol_options,
8267 Some(&previous_hashes),
8268 Some(&target_paths),
8269 &protected_purpose_paths,
8270 control,
8271 symbol_limits,
8272 )?;
8273 let graph = graph_projection::stage_incremental_repository_graph(
8274 store,
8275 root,
8276 base_generation,
8277 &expected_nodes,
8278 &direct_document_paths.into_iter().collect::<Vec<_>>(),
8279 &scan_policy,
8280 &symbols,
8281 control,
8282 )?;
8283 let structural_summaries = stage_structural_summaries_for_nodes_controlled(
8284 store,
8285 &nodes,
8286 &text.rows,
8287 Some(&symbols),
8288 &protected_purpose_paths,
8289 symbol_options.effective_workers(),
8290 control,
8291 )?;
8292 enforce_publication_staging_budget(
8293 retained_before_symbols
8294 .saturating_add(symbols.retained_bytes)
8295 .saturating_add(graph.retained_bytes())
8296 .saturating_add(structural_summaries.retained_bytes),
8297 )?;
8298 let batch = IndexPublicationBatch {
8299 base_generation,
8300 contract_fingerprint,
8301 root: root.clone(),
8302 nodes: NodePublicationBatch::Incremental {
8303 nodes,
8304 absent_paths,
8305 expected_nodes,
8306 },
8307 purpose_import: None,
8308 text_paths,
8309 text,
8310 content_classifications,
8311 symbols,
8312 graph,
8313 structural_summaries,
8314 };
8315 revalidate_staged_publication_inputs_controlled(
8316 plan,
8317 batch.nodes.expected_nodes(),
8318 None,
8319 control,
8320 )?;
8321 let outcome = publish_index_batch(store, batch, control)?;
8322 Ok(IndexRefreshReport {
8323 text_index: outcome.text_index,
8324 structural_summaries: outcome.structural_summaries,
8325 symbols: outcome.symbols,
8326 })
8327}
8328
8329pub(crate) fn seed_builtin_projectatlas_purposes(
8331 store: &AtlasStore,
8332 nodes: &[Node],
8333) -> Result<(), CliError> {
8334 let indexed_paths = nodes
8335 .iter()
8336 .map(|node| node.path.as_str())
8337 .collect::<HashSet<_>>();
8338 for (path, purpose) in BUILTIN_PROJECTATLAS_PURPOSES {
8339 if !indexed_paths.contains(path) {
8340 continue;
8341 }
8342 let Some(indexed) = store.load_node_by_path(path)? else {
8343 continue;
8344 };
8345 if !matches!(
8346 indexed.purpose.status,
8347 PurposeStatus::Approved | PurposeStatus::Stale
8348 ) {
8349 store.set_purpose(path, purpose, PurposeSource::Imported)?;
8350 }
8351 }
8352 Ok(())
8353}
8354
8355#[cfg(test)]
8357pub(crate) fn refresh_structural_summaries_for_nodes(
8358 store: &mut AtlasStore,
8359 nodes: &[Node],
8360 text_rows: &[TextIndexRow],
8361) -> Result<StructuralSummaryReport, CliError> {
8362 let control = standalone_index_work_control();
8363 refresh_structural_summaries_for_nodes_controlled(store, nodes, text_rows, &control)
8364}
8365
8366#[cfg(test)]
8368fn refresh_structural_summaries_for_nodes_controlled(
8369 store: &mut AtlasStore,
8370 nodes: &[Node],
8371 text_rows: &[TextIndexRow],
8372 control: &IndexWorkControl,
8373) -> Result<StructuralSummaryReport, CliError> {
8374 let staged = stage_structural_summaries_for_nodes_controlled(
8375 store,
8376 nodes,
8377 text_rows,
8378 None,
8379 &HashSet::new(),
8380 2,
8381 control,
8382 )?;
8383 apply_structural_summary_stage(store, &staged, control)?;
8384 Ok(staged.report)
8385}
8386
8387fn stage_structural_summaries_for_nodes_controlled(
8389 store: &AtlasStore,
8390 nodes: &[Node],
8391 text_rows: &[TextIndexRow],
8392 symbols: Option<&SymbolBuildStage>,
8393 protected_purpose_paths: &HashSet<String>,
8394 max_workers: usize,
8395 control: &IndexWorkControl,
8396) -> Result<StructuralSummaryStage, CliError> {
8397 control.check(IndexWorkStage::TextIndex)?;
8398 let candidates = nodes
8399 .iter()
8400 .filter(|node| node.kind == NodeKind::File)
8401 .filter(|node| is_structural_summary_candidate(&node.path, node.language.as_deref()))
8402 .collect::<Vec<_>>();
8403 if candidates.is_empty() {
8404 return Ok(StructuralSummaryStage {
8405 report: StructuralSummaryReport::default(),
8406 changes: Vec::new(),
8407 retained_bytes: 0,
8408 });
8409 }
8410 let paths = candidates
8411 .iter()
8412 .map(|node| node.path.clone())
8413 .collect::<Vec<_>>();
8414 let indexed_nodes = store
8415 .load_nodes_by_paths(&paths)?
8416 .into_iter()
8417 .map(|indexed| (indexed.node.path.clone(), indexed))
8418 .collect::<HashMap<_, _>>();
8419 let symbol_counts = store.symbol_counts_for_paths(&paths)?;
8420 let text_by_path = text_rows
8421 .iter()
8422 .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
8423 .collect::<HashMap<_, _>>();
8424 let reason_by_path = text_rows
8425 .iter()
8426 .map(|row| (row.path.as_str(), row.reason))
8427 .collect::<HashMap<_, _>>();
8428 let mut staged_symbol_counts = HashMap::new();
8429 let mut staged_symbol_summaries = HashMap::new();
8430 let mut staged_structural_summaries = HashMap::new();
8431 if let Some(symbols) = symbols {
8432 for change in &symbols.changes {
8433 match change {
8434 SymbolProjectionChange::Parsed(parsed) => {
8435 staged_symbol_counts.insert(parsed.path.as_str(), parsed.graph.symbols.len());
8436 staged_symbol_summaries.insert(parsed.path.as_str(), parsed.summary.as_str());
8437 if parsed.summary_is_structural {
8438 staged_structural_summaries
8439 .insert(parsed.path.as_str(), parsed.purpose_suggestion.is_some());
8440 }
8441 }
8442 SymbolProjectionChange::Clear { path, .. } => {
8443 staged_symbol_counts.insert(path.as_str(), 0);
8444 }
8445 }
8446 }
8447 }
8448 let mut report = StructuralSummaryReport {
8449 candidates: paths.len(),
8450 ..StructuralSummaryReport::default()
8451 };
8452 let worker_count = worker_count_for_work(candidates.len(), max_workers);
8453 let pool = ThreadPoolBuilder::new()
8454 .num_threads(worker_count)
8455 .build()
8456 .map_err(|source| {
8457 CliError::InvalidInput(format!("structural summary worker pool failed: {source}"))
8458 })?;
8459 let derivations = pool.install(|| {
8460 candidates
8461 .par_iter()
8462 .map(|node| -> Result<StructuralSummaryDerivation, CliError> {
8463 control.check(IndexWorkStage::TextIndex)?;
8464 let existing = indexed_nodes.get(&node.path);
8465 let max_bytes = source_input_byte_limit(
8466 &node.path,
8467 node.language.as_deref(),
8468 node.size_bytes,
8469 MAX_SYMBOL_FILE_BYTES,
8470 IndexWorkStage::TextIndex,
8471 )?;
8472 if reason_by_path.get(node.path.as_str()) == Some(&TextIndexSkipReason::TooLarge)
8473 || node
8474 .size_bytes
8475 .is_some_and(|size_bytes| size_bytes > max_bytes)
8476 {
8477 return Ok(StructuralSummaryDerivation {
8478 change: Some(StructuralSummaryChange::Clear {
8479 path: node.path.clone(),
8480 }),
8481 cleared: 1,
8482 too_large: 1,
8483 retained_bytes: node.path.len() as u64,
8484 ..StructuralSummaryDerivation::default()
8485 });
8486 }
8487 let Some(text) = text_by_path.get(node.path.as_str()) else {
8488 return Ok(StructuralSummaryDerivation {
8489 change: Some(StructuralSummaryChange::Clear {
8490 path: node.path.clone(),
8491 }),
8492 cleared: 1,
8493 binary_or_non_utf8: usize::from(
8494 reason_by_path.get(node.path.as_str())
8495 == Some(&TextIndexSkipReason::BinaryOrNonUtf8),
8496 ),
8497 retained_bytes: node.path.len() as u64,
8498 ..StructuralSummaryDerivation::default()
8499 });
8500 };
8501 if let Some(purpose_suggested) = staged_structural_summaries.get(node.path.as_str())
8502 {
8503 return Ok(StructuralSummaryDerivation {
8504 summarized: 1,
8505 purpose_suggestions: usize::from(*purpose_suggested),
8506 ..StructuralSummaryDerivation::default()
8507 });
8508 }
8509 let symbol_count = staged_symbol_counts
8510 .get(node.path.as_str())
8511 .copied()
8512 .or_else(|| symbol_counts.get(node.path.as_str()).copied())
8513 .unwrap_or_default();
8514 let effective_summary = staged_symbol_summaries
8515 .get(node.path.as_str())
8516 .copied()
8517 .or_else(|| existing.and_then(|indexed| indexed.summary.as_deref()));
8518 if symbol_count > 0
8519 && effective_summary.is_some_and(|summary| {
8520 !summary.trim().is_empty() && !is_scanner_fallback_summary(summary)
8521 })
8522 {
8523 return Ok(StructuralSummaryDerivation::default());
8524 }
8525 let Some(summary) = structural_summary_for_path(
8526 &node.path,
8527 node.language.as_deref(),
8528 &text.content,
8529 ) else {
8530 return Ok(StructuralSummaryDerivation {
8531 change: Some(StructuralSummaryChange::Clear {
8532 path: node.path.clone(),
8533 }),
8534 cleared: 1,
8535 retained_bytes: node.path.len() as u64,
8536 ..StructuralSummaryDerivation::default()
8537 });
8538 };
8539 let purpose_needs_suggestion = !protected_purpose_paths.contains(&node.path)
8540 && existing.is_none_or(|indexed| {
8541 matches!(
8542 indexed.purpose.status,
8543 PurposeStatus::Missing | PurposeStatus::Suggested
8544 )
8545 });
8546 let purpose_suggestion =
8547 purpose_needs_suggestion.then(|| suggest_file_purpose(&node.path, &summary));
8548 let purpose_suggestions = usize::from(purpose_suggestion.is_some());
8549 let retained_bytes = (node.path.len() as u64)
8550 .saturating_add(summary.len() as u64)
8551 .saturating_add(
8552 purpose_suggestion
8553 .as_ref()
8554 .map_or(0, |suggestion| suggestion.len() as u64),
8555 );
8556 control.check(IndexWorkStage::TextIndex)?;
8557 Ok(StructuralSummaryDerivation {
8558 change: Some(StructuralSummaryChange::Set {
8559 path: node.path.clone(),
8560 summary,
8561 purpose_suggestion,
8562 }),
8563 summarized: 1,
8564 purpose_suggestions,
8565 retained_bytes,
8566 ..StructuralSummaryDerivation::default()
8567 })
8568 })
8569 .collect::<Result<Vec<_>, CliError>>()
8570 })?;
8571 let mut changes = Vec::new();
8572 let mut retained_bytes = 0_u64;
8573 for derivation in derivations {
8574 report.summarized += derivation.summarized;
8575 report.cleared += derivation.cleared;
8576 report.too_large += derivation.too_large;
8577 report.binary_or_non_utf8 += derivation.binary_or_non_utf8;
8578 report.purpose_suggestions += derivation.purpose_suggestions;
8579 retained_bytes = retained_bytes.saturating_add(derivation.retained_bytes);
8580 if let Some(change) = derivation.change {
8581 changes.push(change);
8582 }
8583 }
8584 Ok(StructuralSummaryStage {
8585 report,
8586 changes,
8587 retained_bytes,
8588 })
8589}
8590
8591fn apply_structural_summary_stage(
8593 store: &mut AtlasStore,
8594 staged: &StructuralSummaryStage,
8595 control: &IndexWorkControl,
8596) -> Result<(), CliError> {
8597 for change in &staged.changes {
8598 control.check(IndexWorkStage::Publication)?;
8599 match change {
8600 StructuralSummaryChange::Set {
8601 path,
8602 summary,
8603 purpose_suggestion,
8604 } => {
8605 store.set_node_summary(path, summary)?;
8606 if let Some(suggestion) = purpose_suggestion.as_deref() {
8607 store.set_suggested_purpose(path, suggestion)?;
8608 }
8609 }
8610 StructuralSummaryChange::Clear { path } => store.clear_node_summary(path)?,
8611 }
8612 }
8613 control.check(IndexWorkStage::Publication)?;
8614 Ok(())
8615}
8616
8617#[cfg(test)]
8619pub(crate) fn refresh_text_index_for_nodes(
8620 store: &mut AtlasStore,
8621 root: &Path,
8622 nodes: &[Node],
8623 options: TextIndexOptions,
8624) -> Result<TextIndexReport, CliError> {
8625 let control = standalone_index_work_control();
8626 Ok(
8627 refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)?
8628 .report,
8629 )
8630}
8631
8632#[cfg(test)]
8634pub(crate) fn refresh_text_index_for_nodes_with_rows(
8635 store: &mut AtlasStore,
8636 root: &Path,
8637 nodes: &[Node],
8638 options: TextIndexOptions,
8639) -> Result<TextIndexRefresh, CliError> {
8640 let control = standalone_index_work_control();
8641 refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)
8642}
8643
8644#[cfg(test)]
8646fn refresh_text_index_for_nodes_with_rows_controlled(
8647 store: &mut AtlasStore,
8648 root: &Path,
8649 nodes: &[Node],
8650 options: TextIndexOptions,
8651 control: &IndexWorkControl,
8652) -> Result<TextIndexRefresh, CliError> {
8653 let file_paths = nodes
8654 .iter()
8655 .filter(|node| node.kind == NodeKind::File)
8656 .map(|node| node.path.clone())
8657 .collect::<Vec<_>>();
8658 refresh_text_index_for_changed_paths_with_rows_controlled(
8659 store,
8660 root,
8661 &file_paths,
8662 nodes,
8663 options,
8664 control,
8665 )
8666}
8667
8668#[cfg(test)]
8670fn refresh_text_index_for_changed_paths_with_rows_controlled(
8671 store: &mut AtlasStore,
8672 root: &Path,
8673 considered_paths: &[String],
8674 nodes: &[Node],
8675 options: TextIndexOptions,
8676 control: &IndexWorkControl,
8677) -> Result<TextIndexRefresh, CliError> {
8678 let staged = stage_text_index_for_changed_paths_controlled(root, nodes, options, control)?;
8679 apply_text_index_stage(store, considered_paths, &staged, control)?;
8680 Ok(staged)
8681}
8682
8683fn stage_text_index_for_changed_paths_controlled(
8685 root: &Path,
8686 nodes: &[Node],
8687 options: TextIndexOptions,
8688 control: &IndexWorkControl,
8689) -> Result<TextIndexRefresh, CliError> {
8690 control.check(IndexWorkStage::TextIndex)?;
8691 let text_rows = indexed_file_texts_for_nodes_controlled(root, nodes, options, control)?;
8692 let indexed = text_rows.iter().filter(|row| row.text.is_some()).count();
8693 let indexed_bytes = text_rows
8694 .iter()
8695 .filter_map(|row| row.text.as_ref())
8696 .map(|text| text.byte_count)
8697 .fold(0usize, usize::saturating_add);
8698 let file_candidates = nodes
8699 .iter()
8700 .filter(|node| node.kind == NodeKind::File)
8701 .count();
8702 let binary_or_non_utf8 = text_rows
8703 .iter()
8704 .filter(|row| row.reason == TextIndexSkipReason::BinaryOrNonUtf8)
8705 .count();
8706 let too_large = text_rows
8707 .iter()
8708 .filter(|row| row.reason == TextIndexSkipReason::TooLarge)
8709 .count();
8710 let report = TextIndexReport {
8711 candidates: file_candidates,
8712 indexed,
8713 binary_or_non_utf8,
8714 too_large,
8715 skipped: file_candidates.saturating_sub(indexed),
8716 max_bytes: options.max_bytes,
8717 bytes: indexed_bytes,
8718 };
8719 control.check(IndexWorkStage::TextIndex)?;
8720 Ok(TextIndexRefresh {
8721 report,
8722 rows: text_rows,
8723 })
8724}
8725
8726fn apply_text_index_stage(
8728 store: &mut AtlasStore,
8729 considered_paths: &[String],
8730 staged: &TextIndexRefresh,
8731 control: &IndexWorkControl,
8732) -> Result<(), CliError> {
8733 let text_by_path = staged
8734 .rows
8735 .iter()
8736 .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
8737 .collect::<HashMap<_, _>>();
8738 for paths in considered_paths.chunks(PUBLICATION_TEXT_BATCH_SIZE) {
8739 control.check(IndexWorkStage::Publication)?;
8740 store.replace_file_texts_for_paths(
8741 paths,
8742 paths
8743 .iter()
8744 .filter_map(|path| text_by_path.get(path.as_str()).copied()),
8745 )?;
8746 }
8747 control.check(IndexWorkStage::Publication)?;
8748 Ok(())
8749}
8750
8751#[cfg(test)]
8753pub(crate) fn indexed_file_texts_for_nodes(
8754 root: &Path,
8755 nodes: &[Node],
8756 options: TextIndexOptions,
8757) -> Result<Vec<TextIndexRow>, CliError> {
8758 let control = standalone_index_work_control();
8759 indexed_file_texts_for_nodes_controlled(root, nodes, options, &control)
8760}
8761
8762fn indexed_file_texts_for_nodes_controlled(
8764 root: &Path,
8765 nodes: &[Node],
8766 options: TextIndexOptions,
8767 control: &IndexWorkControl,
8768) -> Result<Vec<TextIndexRow>, CliError> {
8769 indexed_file_texts_for_nodes_with_limit(root, nodes, options, MAX_STAGED_TEXT_BYTES, control)
8770}
8771
8772fn indexed_file_texts_for_nodes_with_limit(
8774 root: &Path,
8775 nodes: &[Node],
8776 options: TextIndexOptions,
8777 max_staged_bytes: u64,
8778 control: &IndexWorkControl,
8779) -> Result<Vec<TextIndexRow>, CliError> {
8780 let mut rows = Vec::new();
8781 let mut staged_bytes = 0_u64;
8782 for node in nodes.iter().filter(|node| node.kind == NodeKind::File) {
8783 control.check(IndexWorkStage::TextIndex)?;
8784 let max_bytes = source_input_byte_limit(
8785 &node.path,
8786 node.language.as_deref(),
8787 node.size_bytes,
8788 options.max_bytes,
8789 IndexWorkStage::TextIndex,
8790 )?;
8791 if node
8792 .size_bytes
8793 .is_some_and(|size_bytes| size_bytes > max_bytes)
8794 {
8795 rows.push(TextIndexRow {
8796 path: node.path.clone(),
8797 text: None,
8798 reason: TextIndexSkipReason::TooLarge,
8799 });
8800 continue;
8801 }
8802 let remaining_staged_bytes = max_staged_bytes.saturating_sub(staged_bytes);
8803 let is_document = document_format_for_path(&node.path, node.language.as_deref()).is_some();
8804 if !is_document
8805 && node
8806 .size_bytes
8807 .is_some_and(|size_bytes| size_bytes > remaining_staged_bytes)
8808 {
8809 return Err(IndexWorkFailure::resource_limit(
8810 IndexWorkStage::TextIndex,
8811 IndexWorkResource::TextBytes,
8812 max_staged_bytes,
8813 staged_bytes.saturating_add(node.size_bytes.unwrap_or_default()),
8814 )
8815 .into());
8816 }
8817 let native_path = root.join(repo_path_to_native(&node.path));
8818 let read_limit = if is_document {
8819 max_bytes
8820 } else {
8821 max_bytes.min(remaining_staged_bytes)
8822 };
8823 let aggregate_limit_is_narrower = !is_document && remaining_staged_bytes <= max_bytes;
8824 let bytes = match read_source_bytes_controlled(
8825 &native_path,
8826 read_limit,
8827 IndexWorkStage::TextIndex,
8828 control,
8829 ) {
8830 Ok(bytes) => bytes,
8831 Err(SourceReadFailure::Io(source)) => {
8832 return Err(CliError::Io {
8833 path: native_path,
8834 source,
8835 });
8836 }
8837 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
8838 Err(SourceReadFailure::LimitExceeded { observed }) if aggregate_limit_is_narrower => {
8839 return Err(IndexWorkFailure::resource_limit(
8840 IndexWorkStage::TextIndex,
8841 IndexWorkResource::TextBytes,
8842 max_staged_bytes,
8843 staged_bytes.saturating_add(observed),
8844 )
8845 .into());
8846 }
8847 Err(SourceReadFailure::LimitExceeded { .. }) => {
8848 return Err(source_changed_during_derivation(root, &node.path));
8849 }
8850 };
8851 control.check(IndexWorkStage::TextIndex)?;
8852 let current_hash = blake3::hash(&bytes).to_hex().to_string();
8853 if node.content_hash.as_deref() != Some(current_hash.as_str()) {
8854 return Err(source_changed_during_derivation(root, &node.path));
8855 }
8856 let content = if is_document {
8857 extract_document_text_controlled(&bytes, &node.path, node.language.as_deref(), control)
8858 .map_err(|error| document_navigation_error(&node.path, error))?
8859 .text
8860 } else {
8861 let Ok(content) = String::from_utf8(bytes) else {
8862 rows.push(TextIndexRow {
8863 path: node.path.clone(),
8864 text: None,
8865 reason: TextIndexSkipReason::BinaryOrNonUtf8,
8866 });
8867 continue;
8868 };
8869 content
8870 };
8871 let next_staged_bytes = staged_bytes.saturating_add(content.len() as u64);
8872 if next_staged_bytes > max_staged_bytes {
8873 return Err(IndexWorkFailure::resource_limit(
8874 IndexWorkStage::TextIndex,
8875 IndexWorkResource::TextBytes,
8876 max_staged_bytes,
8877 next_staged_bytes,
8878 )
8879 .into());
8880 }
8881 staged_bytes = next_staged_bytes;
8882 rows.push(TextIndexRow {
8883 path: node.path.clone(),
8884 reason: TextIndexSkipReason::Indexed,
8885 text: Some(IndexedFileText {
8886 path: node.path.clone(),
8887 content_hash: node.content_hash.clone(),
8888 byte_count: content.len(),
8889 line_count: content.lines().count(),
8890 content,
8891 }),
8892 });
8893 }
8894 Ok(rows)
8895}
8896
8897pub(crate) fn indexed_file_hashes(store: &AtlasStore) -> Result<HashMap<String, String>, CliError> {
8899 Ok(store
8900 .load_nodes()?
8901 .into_iter()
8902 .filter(|node| node.node.kind == NodeKind::File)
8903 .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
8904 .collect::<HashMap<_, _>>())
8905}
8906
8907pub(crate) fn indexed_file_hashes_for_paths(
8909 store: &AtlasStore,
8910 paths: &HashSet<String>,
8911) -> Result<HashMap<String, String>, CliError> {
8912 let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
8913 sorted_paths.sort();
8914 Ok(store
8915 .load_nodes_by_paths(&sorted_paths)?
8916 .into_iter()
8917 .filter(|node| node.node.kind == NodeKind::File)
8918 .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
8919 .collect::<HashMap<_, _>>())
8920}
8921
8922pub(crate) fn sorted_watch_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
8924 let mut paths = paths.iter().cloned().collect::<Vec<_>>();
8925 paths.sort();
8926 paths
8927}
8928
8929pub(crate) fn normalized_deleted_path(
8931 root: &Path,
8932 path: &Path,
8933) -> Result<Option<String>, CliError> {
8934 match normalize_repo_path(root, path) {
8935 Ok(path) => Ok(valid_watch_relative_path(path)),
8936 Err(projectatlas_core::CoreError::PathOutsideRoot { .. }) => {
8937 Ok(native_display_relative_path(root, path).and_then(valid_watch_relative_path))
8938 }
8939 Err(source) => Err(CliError::InvalidInput(source.to_string())),
8940 }
8941}
8942
8943pub(crate) fn strip_legacy_purpose(
8945 root: &Path,
8946 config_path: Option<&Path>,
8947 apply: bool,
8948 dry_run: bool,
8949 strip_source_headers: bool,
8950) -> Result<LegacyPurposeReport, CliError> {
8951 let root = root.canonicalize().map_err(|source| CliError::Io {
8952 path: root.to_path_buf(),
8953 source,
8954 })?;
8955 let scan_options = scan_options_for_root(config_path, &root)?;
8956 let nodes = scan_repo(&root, &scan_options)?;
8957 let effective_dry_run = dry_run || !apply;
8958 let purpose_files = indexed_purpose_files(&root, &nodes);
8959 let mut removed = 0;
8960 if !effective_dry_run {
8961 for path in &purpose_files {
8962 let native = root.join(repo_path_to_native(path));
8963 fs::remove_file(&native).map_err(|source| CliError::Io {
8964 path: native,
8965 source,
8966 })?;
8967 removed += 1;
8968 }
8969 }
8970 let source_header_candidates = if strip_source_headers {
8971 purpose_header_candidates(&root, &nodes)?
8972 } else {
8973 Vec::new()
8974 };
8975 Ok(LegacyPurposeReport {
8976 applied: !effective_dry_run,
8977 purpose_files_found: purpose_files.len(),
8978 purpose_files_removed: removed,
8979 source_header_candidates,
8980 purpose_files,
8981 })
8982}
8983
8984pub(crate) fn indexed_purpose_files(root: &Path, nodes: &[Node]) -> Vec<String> {
8986 let mut purpose_files = Vec::new();
8987 for node in nodes.iter().filter(|node| node.kind == NodeKind::Folder) {
8988 let relative = if node.path == "." {
8989 ".purpose".to_string()
8990 } else {
8991 format!("{}/.purpose", node.path)
8992 };
8993 let native = root.join(repo_path_to_native(&relative));
8994 if native.exists() {
8995 purpose_files.push(relative);
8996 }
8997 }
8998 purpose_files.sort();
8999 purpose_files
9000}
9001
9002pub(crate) fn purpose_header_candidates(
9004 root: &Path,
9005 nodes: &[Node],
9006) -> Result<Vec<String>, CliError> {
9007 let mut candidates = Vec::new();
9008 for node in nodes
9009 .iter()
9010 .filter(|node| node.kind == NodeKind::File)
9011 .filter(|node| is_symbol_candidate(&node.path, node.language.as_deref()))
9012 {
9013 let path = root.join(repo_path_to_native(&node.path));
9014 let content = fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?;
9015 if content
9016 .lines()
9017 .take(3)
9018 .any(|line| line.trim_start().contains("Purpose:"))
9019 {
9020 candidates.push(node.path.clone());
9021 }
9022 }
9023 Ok(candidates)
9024}
9025
9026#[cfg(test)]
9027mod tests {
9028 use super::*;
9029 use projectatlas_core::graph::{
9030 DocumentTargetUnresolvedReason, EntitySelector, ExtendedRelationKind, GraphRelationKind,
9031 RelationResolution, RepositoryNodePath,
9032 };
9033 use projectatlas_db::{DbError, RepositoryGraphRelationQuery, WorktreeAlias};
9034 use std::error::Error;
9035 use std::fmt::Debug;
9036 use std::process::Command as StdCommand;
9037
9038 fn run_git_fixture(command: &mut StdCommand) -> Result<(), Box<dyn Error>> {
9039 let output = command.output()?;
9040 if !output.status.success() {
9041 return Err(io::Error::other(format!(
9042 "Git fixture command failed: {}{}",
9043 String::from_utf8_lossy(&output.stdout),
9044 String::from_utf8_lossy(&output.stderr)
9045 ))
9046 .into());
9047 }
9048 Ok(())
9049 }
9050
9051 fn drop_native_worktree_identity_schema(
9053 connection: &rusqlite::Connection,
9054 ) -> rusqlite::Result<()> {
9055 connection.execute_batch(
9056 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
9057 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
9058 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
9059 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
9060 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
9061 )
9062 }
9063
9064 #[test]
9065 fn synchronization_control_identity_rejects_replacement_without_caller_identity()
9066 -> Result<(), Box<dyn Error>> {
9067 let temp = tempfile::tempdir()?;
9068 let root = temp.path().join("control");
9069 let state = root.join(".projectatlas");
9070 let database = state.join("projectatlas.db");
9071 fs::create_dir_all(&state)?;
9072 let original = AtlasStore::open_for_project(&database, &root)?;
9073 let captured = require_synchronization_control_identity(&original, None)?;
9074 require_eq(
9075 &require_synchronization_control_identity(&original, Some(captured))?,
9076 &captured,
9077 "stable control identity",
9078 )?;
9079 drop(original);
9080
9081 fs::rename(&state, root.join(".projectatlas-captured-control"))?;
9082 fs::create_dir(&state)?;
9083 let replacement = AtlasStore::open_for_project(&database, &root)?;
9084 let rejected = require_synchronization_control_identity(&replacement, Some(captured));
9085 require_eq(
9086 &rejected
9087 .as_ref()
9088 .is_err_and(|error| error.to_string().contains("control atlas identity changed")),
9089 &true,
9090 "replacement control identity rejection",
9091 )
9092 }
9093
9094 #[cfg(windows)]
9095 #[test]
9096 fn default_mcp_project_root_recovers_custom_predecessor_candidate() -> Result<(), Box<dyn Error>>
9097 {
9098 let temp = tempfile::tempdir()?;
9099 let root = temp.path().join("legacy-project");
9100 let database = temp.path().join("custom-projectatlas.db");
9101 fs::create_dir(&root)?;
9102 drop(AtlasStore::open_for_project(&database, &root)?);
9103 {
9104 let connection = rusqlite::Connection::open(&database)?;
9105 drop_native_worktree_identity_schema(&connection)?;
9106 connection.execute_batch(
9107 "DROP TABLE project_root_identity;
9108 DROP TABLE IF EXISTS graph_identity_rejections;
9109 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9110 )?;
9111 }
9112
9113 let resolved = default_mcp_project_root(&database, None)?;
9114 require_eq(
9115 &resolved,
9116 &canonical_source_project_root(&root)?,
9117 "custom predecessor root recovery",
9118 )?;
9119 drop(AtlasStore::open_for_project(&database, &root)?);
9120 Ok(())
9121 }
9122
9123 #[test]
9124 fn init_rejects_current_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
9125 let temp = tempfile::tempdir()?;
9126 let selected_root = temp.path().join("selected-root");
9127 let bound_root = temp.path().join("bound-root");
9128 let database = temp.path().join("external-projectatlas.db");
9129 let config_path = selected_root.join("external-config/config.toml");
9130 fs::create_dir_all(&selected_root)?;
9131 fs::create_dir_all(&bound_root)?;
9132 let persisted_identity = {
9133 let store = AtlasStore::open_for_project(&database, &bound_root)?;
9134 store.project_root_identity()?
9135 };
9136 drop(AtlasStore::open_read_only_for_project(
9137 &database,
9138 &bound_root,
9139 )?);
9140 let _ = read_project_root_identity_read_only(&database)?;
9141 let database_before = fs::read(&database)?;
9142 let sidecars_before = ["wal", "shm", "journal"]
9143 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9144 let selected_project_dir = selected_root.join(".projectatlas");
9145 let config_parent = config_path
9146 .parent()
9147 .ok_or_else(|| io::Error::other("selected config has no parent"))?;
9148
9149 let result = run_init_bootstrap(
9150 &selected_root,
9151 &database,
9152 Some(&config_path),
9153 &InitBootstrapOptions {
9154 no_scan: true,
9155 force_rescan: false,
9156 text_index_max_bytes: None,
9157 },
9158 );
9159 require_eq(
9160 &matches!(result, Err(CliError::ProjectMismatch(_))),
9161 &true,
9162 "current wrong-root init did not return a typed project mismatch",
9163 )?;
9164 require_eq(
9165 &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
9166 &true,
9167 "current wrong-root init created selected project or config state",
9168 )?;
9169 require_eq(
9170 &(fs::read(&database)? == database_before
9171 && ["wal", "shm", "journal"]
9172 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
9173 == sidecars_before),
9174 &true,
9175 "current wrong-root init changed database or sidecar state",
9176 )?;
9177 let reopened = AtlasStore::open_read_only_for_project(&database, &bound_root)?;
9178 require_eq(
9179 &reopened.project_root_identity()?,
9180 &persisted_identity,
9181 "current binding after wrong-root init",
9182 )?;
9183 Ok(())
9184 }
9185
9186 #[test]
9187 fn init_repairs_current_missing_root_identity_from_bound_metadata() -> Result<(), Box<dyn Error>>
9188 {
9189 let temp = tempfile::tempdir()?;
9190 let root = temp.path().join("incomplete-binding-root");
9191 let database = temp.path().join("incomplete-binding.db");
9192 fs::create_dir(&root)?;
9193 let project_instance_id = {
9194 let store = AtlasStore::open_for_project(&database, &root)?;
9195 store
9196 .project_instance_id()?
9197 .ok_or_else(|| io::Error::other("incomplete binding project identity is missing"))?
9198 };
9199 let connection = rusqlite::Connection::open(&database)?;
9200 connection.execute("DELETE FROM project_root_identity", [])?;
9201 drop(connection);
9202
9203 let ordinary_error = AtlasStore::open_for_project(&database, &root)
9204 .err()
9205 .ok_or_else(|| io::Error::other("ordinary open repaired incomplete binding"))?;
9206 require_eq(
9207 &matches!(ordinary_error, DbError::ProjectRootIdentityMissing),
9208 &true,
9209 "ordinary open returned the wrong incomplete-binding error",
9210 )?;
9211
9212 let wrong_root = temp.path().join("incomplete-binding-wrong-root");
9213 fs::create_dir(&wrong_root)?;
9214 let database_before = fs::read(&database)?;
9215 let sidecars_before = ["wal", "shm", "journal"]
9216 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9217 let wrong_result = run_init_bootstrap(
9218 &wrong_root,
9219 &database,
9220 None,
9221 &InitBootstrapOptions {
9222 no_scan: true,
9223 force_rescan: false,
9224 text_index_max_bytes: None,
9225 },
9226 );
9227 require_eq(
9228 &matches!(wrong_result, Err(CliError::ProjectMismatch(_))),
9229 &true,
9230 "incomplete-binding init accepted a different root",
9231 )?;
9232 require_eq(
9233 &fs::read(&database)?,
9234 &database_before,
9235 "wrong-root incomplete-binding init changed database bytes",
9236 )?;
9237 require_eq(
9238 &["wal", "shm", "journal"]
9239 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok()),
9240 &sidecars_before,
9241 "wrong-root incomplete-binding init changed SQLite sidecars",
9242 )?;
9243
9244 let report = run_init_bootstrap(
9245 &root,
9246 &database,
9247 None,
9248 &InitBootstrapOptions {
9249 no_scan: true,
9250 force_rescan: false,
9251 text_index_max_bytes: None,
9252 },
9253 )?;
9254 require_eq(
9255 &report.ok,
9256 &true,
9257 "explicit init did not repair the incomplete binding",
9258 )?;
9259
9260 let reopened = AtlasStore::open_for_project(&database, &root)?;
9261 require_eq(
9262 &reopened.project_instance_id()?,
9263 &Some(project_instance_id),
9264 "explicit init changed the project identity while repairing the root",
9265 )?;
9266 require_eq(
9267 &reopened.project_root_identity()?,
9268 &Some(projectatlas_core::CanonicalProjectRoot::from_path(&root)?),
9269 "explicit init did not restore the native root identity",
9270 )?;
9271 Ok(())
9272 }
9273
9274 #[cfg(windows)]
9275 #[test]
9276 fn init_rejects_predecessor_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
9277 let temp = tempfile::tempdir()?;
9278 let selected_root = temp.path().join("selected-predecessor-root");
9279 let bound_root = temp.path().join("bound-predecessor-root");
9280 let database = temp.path().join("external-predecessor.db");
9281 let config_path = selected_root.join("external-config/config.toml");
9282 fs::create_dir_all(&selected_root)?;
9283 fs::create_dir_all(&bound_root)?;
9284 drop(AtlasStore::open_for_project(&database, &bound_root)?);
9285 {
9286 let connection = rusqlite::Connection::open(&database)?;
9287 drop_native_worktree_identity_schema(&connection)?;
9288 connection.execute_batch(
9289 "DROP TABLE project_root_identity;
9290 DROP TABLE IF EXISTS graph_identity_rejections;
9291 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9292 )?;
9293 }
9294 read_legacy_project_root_candidate_read_only(&database)?;
9295 let database_before = fs::read(&database)?;
9296 let sidecars_before = ["wal", "shm", "journal"]
9297 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9298 let selected_project_dir = selected_root.join(".projectatlas");
9299 let config_parent = config_path
9300 .parent()
9301 .ok_or_else(|| io::Error::other("selected predecessor config has no parent"))?;
9302
9303 let result = run_init_bootstrap(
9304 &selected_root,
9305 &database,
9306 Some(&config_path),
9307 &InitBootstrapOptions {
9308 no_scan: true,
9309 force_rescan: false,
9310 text_index_max_bytes: None,
9311 },
9312 );
9313 require_eq(
9314 &matches!(result, Err(CliError::ProjectMismatch(_))),
9315 &true,
9316 "predecessor wrong-root init did not return a typed project mismatch",
9317 )?;
9318 require_eq(
9319 &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
9320 &true,
9321 "predecessor wrong-root init created selected project or config state",
9322 )?;
9323 require_eq(
9324 &(fs::read(&database)? == database_before
9325 && ["wal", "shm", "journal"]
9326 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
9327 == sidecars_before),
9328 &true,
9329 "predecessor wrong-root init changed database or sidecar state",
9330 )?;
9331 Ok(())
9332 }
9333
9334 #[cfg(unix)]
9335 #[test]
9336 fn runtime_rejects_non_authoritative_predecessor_before_config_discovery()
9337 -> Result<(), Box<dyn Error>> {
9338 fn is_ambiguous_predecessor(error: &CliError) -> bool {
9339 matches!(error, CliError::Db(DbError::ProjectRootIdentityMissing))
9340 }
9341
9342 use std::ffi::OsString;
9343 use std::os::unix::ffi::OsStringExt;
9344
9345 let temp = tempfile::tempdir()?;
9346 let raw_root = temp
9347 .path()
9348 .join(OsString::from_vec(b"runtime-repo\\name".to_vec()));
9349 let replacement_root = temp.path().join("runtime-repo/name");
9350 let database = temp.path().join("runtime-custom-predecessor.db");
9351 fs::create_dir_all(&raw_root)?;
9352 drop(AtlasStore::open_for_project(&database, &raw_root)?);
9353 {
9354 let connection = rusqlite::Connection::open(&database)?;
9355 drop_native_worktree_identity_schema(&connection)?;
9356 connection.execute_batch(
9357 "DROP TABLE project_root_identity;
9358 DROP TABLE IF EXISTS graph_identity_rejections;
9359 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
9360 )?;
9361 connection.execute(
9362 "INSERT INTO metadata(key, value) VALUES('project_root', ?1)
9363 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
9364 [replacement_root.to_string_lossy().into_owned()],
9365 )?;
9366 }
9367 let replacement_config = replacement_root.join(".projectatlas/config.toml");
9368 fs::create_dir_all(
9369 replacement_config
9370 .parent()
9371 .ok_or_else(|| io::Error::other("replacement config has no parent"))?,
9372 )?;
9373 fs::write(
9374 &replacement_config,
9375 format!(
9376 "[project]\nroot = {}\n",
9377 serde_json::to_string(&replacement_root.to_string_lossy())?
9378 ),
9379 )?;
9380 let replacement_config_before = fs::read(&replacement_config)?;
9381 require_condition(
9382 matches!(
9383 read_legacy_project_root_candidate_read_only(&database),
9384 Err(DbError::ProjectRootIdentityMissing)
9385 ),
9386 "non-authoritative predecessor candidate was exposed",
9387 )?;
9388 let database_before = fs::read(&database)?;
9389 let sidecars_before = ["wal", "shm", "journal"]
9390 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
9391
9392 require_condition(
9393 default_mcp_project_root(&database, None)
9394 .is_err_and(|error| is_ambiguous_predecessor(&error)),
9395 "default MCP discovery admitted an ambiguous predecessor",
9396 )?;
9397 require_condition(
9398 default_cli_project_root(&database, None, false)
9399 .is_err_and(|error| is_ambiguous_predecessor(&error)),
9400 "default CLI discovery admitted an ambiguous predecessor",
9401 )?;
9402 require_condition(
9403 resolved_mcp_config_path(&database, None)
9404 .is_err_and(|error| is_ambiguous_predecessor(&error)),
9405 "MCP config discovery admitted an ambiguous predecessor",
9406 )?;
9407 require_condition(
9408 resolved_mcp_config_path(&database, Some(&replacement_config))
9409 .is_err_and(|error| is_ambiguous_predecessor(&error)),
9410 "explicit MCP config bypassed ambiguous predecessor admission",
9411 )?;
9412 require_condition(
9413 crate::build_harness_mcp_config_report(
9414 crate::HarnessConfig::McpJson,
9415 "ambiguous-predecessor",
9416 &database,
9417 None,
9418 false,
9419 )
9420 .is_err_and(|error| is_ambiguous_predecessor(&error)),
9421 "generated MCP config admitted an ambiguous predecessor",
9422 )?;
9423 require_condition(
9424 build_settings_report(&database, None, OutputFormat::Json)
9425 .is_err_and(|error| is_ambiguous_predecessor(&error)),
9426 "settings discovery admitted an ambiguous predecessor",
9427 )?;
9428 require_condition(
9429 fs::read(&replacement_config)? == replacement_config_before,
9430 "ambiguous predecessor discovery changed the replacement config",
9431 )?;
9432 require_condition(
9433 fs::read(&database)? == database_before
9434 && ["wal", "shm", "journal"]
9435 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
9436 == sidecars_before,
9437 "ambiguous predecessor discovery changed database or sidecars",
9438 )?;
9439 Ok(())
9440 }
9441
9442 #[test]
9443 fn synchronized_repository_read_holds_catalog_writer_exclusion() -> Result<(), Box<dyn Error>> {
9444 let temp = tempfile::tempdir()?;
9445 let root = temp.path().join("control");
9446 let database = root.join(".projectatlas/projectatlas.db");
9447 fs::create_dir_all(
9448 database
9449 .parent()
9450 .ok_or_else(|| io::Error::other("control database has no parent"))?,
9451 )?;
9452 let control = AtlasStore::open_for_project(&database, &root)?;
9453 let control_project = control
9454 .project_instance_id()?
9455 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
9456 let writer_blocked = std::cell::Cell::new(false);
9457
9458 let overview = synchronize_registered_worktree_usage_with_catalog_validation(
9459 &database,
9460 &root,
9461 Some(control_project),
9462 |_, _| Ok(()),
9463 || Ok(()),
9464 |reader| {
9465 let contender = rusqlite::Connection::open(&database).map_err(DbError::from)?;
9466 contender
9467 .busy_timeout(Duration::ZERO)
9468 .map_err(DbError::from)?;
9469 match contender.execute_batch("BEGIN IMMEDIATE") {
9470 Err(rusqlite::Error::SqliteFailure(code, _))
9471 if matches!(
9472 code.code,
9473 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
9474 ) =>
9475 {
9476 writer_blocked.set(true);
9477 }
9478 Ok(()) => contender.execute_batch("ROLLBACK").map_err(DbError::from)?,
9479 Err(error) => return Err(DbError::from(error)),
9480 }
9481 reader.repository_token_overview()
9482 },
9483 )?;
9484 require_eq(
9485 &writer_blocked.get(),
9486 &true,
9487 "repository aggregate read writer exclusion",
9488 )?;
9489 require_eq(&overview.calls, &0, "empty synchronized repository report")
9490 }
9491
9492 #[test]
9493 fn synchronization_rejects_a_registration_committed_after_catalog_capture()
9494 -> Result<(), Box<dyn Error>> {
9495 let temp = tempfile::tempdir()?;
9496 let root = temp.path().join("control");
9497 let database = root.join(".projectatlas/projectatlas.db");
9498 let common = temp.path().join("common.git");
9499 let admin = common.join("worktrees/added");
9500 let target = temp.path().join("added");
9501 for path in [&root, &admin, &target] {
9502 fs::create_dir_all(path)?;
9503 }
9504 fs::create_dir_all(
9505 database
9506 .parent()
9507 .ok_or_else(|| io::Error::other("control database has no parent"))?,
9508 )?;
9509 let control = AtlasStore::open_for_project(&database, &root)?;
9510 let control_project = control
9511 .project_instance_id()?
9512 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
9513 let alias = WorktreeAlias::parse("added")?;
9514
9515 let result = synchronize_registered_worktree_usage_with_catalog_validation(
9516 &database,
9517 &root,
9518 Some(control_project),
9519 |_, _| Ok(()),
9520 || {
9521 control.register_worktree(
9522 &alias,
9523 &common,
9524 &admin,
9525 &"01".repeat(32),
9526 &target,
9527 None,
9528 1,
9529 )?;
9530 Ok(())
9531 },
9532 |_| Ok(()),
9533 );
9534 require_eq(
9535 &result.as_ref().is_err_and(|error| {
9536 error
9537 .to_string()
9538 .contains("catalog changed during aggregate synchronization")
9539 }),
9540 &true,
9541 "concurrent registration catalog rejection",
9542 )?;
9543 require_eq(
9544 &control.worktree_registration(&alias)?.registration_id,
9545 &1,
9546 "concurrently committed registration",
9547 )
9548 }
9549
9550 #[test]
9551 fn unbound_worktree_synchronization_revalidates_git_lifecycle_before_binding()
9552 -> Result<(), Box<dyn Error>> {
9553 let temp = tempfile::tempdir()?;
9554 let primary = temp.path().join("primary");
9555 let linked = temp.path().join("linked");
9556 fs::create_dir(&primary)?;
9557 run_git_fixture(StdCommand::new("git").current_dir(&primary).arg("init"))?;
9558 for (key, value) in [
9559 ("user.name", "ProjectAtlas Test"),
9560 ("user.email", "projectatlas@example.invalid"),
9561 ("commit.gpgsign", "false"),
9562 ("core.autocrlf", "false"),
9563 ] {
9564 run_git_fixture(
9565 StdCommand::new("git")
9566 .current_dir(&primary)
9567 .args(["config", key, value]),
9568 )?;
9569 }
9570 fs::write(primary.join("lib.rs"), "pub fn primary() {}\n")?;
9571 run_git_fixture(
9572 StdCommand::new("git")
9573 .current_dir(&primary)
9574 .args(["add", "."]),
9575 )?;
9576 run_git_fixture(
9577 StdCommand::new("git")
9578 .current_dir(&primary)
9579 .args(["commit", "-m", "fixture"]),
9580 )?;
9581 run_git_fixture(
9582 StdCommand::new("git")
9583 .current_dir(&primary)
9584 .args(["worktree", "add", "-b", "captured"])
9585 .arg(&linked),
9586 )?;
9587
9588 let RepositoryStructure::Git(repository) =
9589 projectatlas_fs::worktree::discover_repository_structure(&primary)?
9590 else {
9591 return Err(io::Error::other("Git fixture was not discovered").into());
9592 };
9593 let root = linked.canonicalize()?;
9594 let entry = repository
9595 .worktrees
9596 .iter()
9597 .find(|entry| match &entry.state {
9598 GitWorktreeState::Active {
9599 root: candidate, ..
9600 } => candidate == &root,
9601 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
9602 })
9603 .ok_or_else(|| io::Error::other("linked fixture was not discovered"))?;
9604 let administrative_directory = entry.administrative_directory.clone();
9605 let administrative_identity = git_administrative_identity(&administrative_directory)?;
9606 let alias = WorktreeAlias::parse("captured")?;
9607 let control_database = primary.join(".projectatlas/projectatlas.db");
9608 fs::create_dir_all(primary.join(".projectatlas"))?;
9609 let control = AtlasStore::open_for_project(&control_database, &primary)?;
9610 let registration = control.register_worktree(
9611 &alias,
9612 &repository.common_directory,
9613 &administrative_directory,
9614 &administrative_identity,
9615 &root,
9616 None,
9617 1,
9618 )?;
9619 require_registered_worktree_lifecycle(®istration, &root)?;
9620
9621 let target_database = root.join(".projectatlas/projectatlas.db");
9622 fs::create_dir_all(root.join(".projectatlas"))?;
9623 let target = AtlasStore::open_for_project(&target_database, &root)?;
9624 target.record_usage(&usage_from_text(
9625 "captured",
9626 "atlas_overview",
9627 None,
9628 None,
9629 "pub fn captured() {}",
9630 "repository overview",
9631 ))?;
9632 let snapshot = target.export_worktree_usage_snapshot()?;
9633 drop(target);
9634
9635 let preserved_target = root.join(".projectatlas-snapshot-race");
9636 let replacement_project = std::cell::Cell::new(None);
9637 let replaced_database = synchronize_registered_worktree_usage_with_catalog_validation(
9638 &control_database,
9639 &primary,
9640 control.project_instance_id()?,
9641 |captured, captured_root| {
9642 require_eq(
9643 &captured.registration_id,
9644 ®istration.registration_id,
9645 "snapshot-race registration",
9646 )
9647 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
9648 if captured_root != root.as_path() {
9649 return Err(CliError::InvalidInput(
9650 "snapshot-race root changed".to_string(),
9651 ));
9652 }
9653 fs::rename(root.join(".projectatlas"), &preserved_target)?;
9654 fs::create_dir(root.join(".projectatlas"))?;
9655 let replacement = AtlasStore::open_for_project(&target_database, &root)?;
9656 replacement_project.set(replacement.project_instance_id()?);
9657 Ok(())
9658 },
9659 || Ok(()),
9660 |_| Ok(()),
9661 );
9662 require_eq(
9663 &replaced_database.as_ref().is_err_and(|error| {
9664 error
9665 .to_string()
9666 .contains("atlas changed after its usage snapshot was captured")
9667 }),
9668 &true,
9669 "same-lifecycle database replacement rejection",
9670 )?;
9671 require_eq(
9672 &(replacement_project.get().is_some()
9673 && replacement_project.get() != Some(snapshot.project_instance_id())),
9674 &true,
9675 "snapshot-race replacement identity",
9676 )?;
9677 let stored = control.worktree_registration(&alias)?;
9678 require_eq(
9679 &(stored.project_instance_id.is_none()
9680 && stored.accepted_telemetry_revision == 0
9681 && control.registered_worktree_token_overview(&alias)?.calls == 0),
9682 &true,
9683 "database replacement rollback state",
9684 )?;
9685 fs::remove_dir_all(root.join(".projectatlas"))?;
9686 fs::rename(&preserved_target, root.join(".projectatlas"))?;
9687
9688 run_git_fixture(
9689 StdCommand::new("git")
9690 .current_dir(&primary)
9691 .args(["worktree", "remove", "--force"])
9692 .arg(&linked),
9693 )?;
9694 run_git_fixture(
9695 StdCommand::new("git")
9696 .current_dir(&primary)
9697 .args(["worktree", "add", "-b", "replacement"])
9698 .arg(&linked),
9699 )?;
9700 fs::create_dir_all(root.join(".projectatlas"))?;
9701 let replacement = AtlasStore::open_for_project(&target_database, &root)?;
9702 let replacement_project = replacement
9703 .project_instance_id()?
9704 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
9705 require_eq(
9706 &(replacement_project != snapshot.project_instance_id()),
9707 &true,
9708 "replacement worktree atlas identity",
9709 )?;
9710 drop(replacement);
9711
9712 let rejected = require_registered_worktree_lifecycle(®istration, &root);
9713 require_eq(
9714 &rejected.as_ref().is_err_and(|error| {
9715 error
9716 .to_string()
9717 .contains("administrative lifecycle changed")
9718 }),
9719 &true,
9720 "replacement Git lifecycle rejection",
9721 )?;
9722 let stored = control.worktree_registration(&alias)?;
9723 require_eq(
9724 &stored.project_instance_id,
9725 &None,
9726 "failed revalidation project binding",
9727 )?;
9728 require_eq(
9729 &stored.accepted_telemetry_revision,
9730 &0,
9731 "failed revalidation telemetry revision",
9732 )?;
9733 require_eq(
9734 &control.registered_worktree_token_overview(&alias)?.calls,
9735 &0,
9736 "failed revalidation token totals",
9737 )?;
9738
9739 control.retire_worktree(registration.registration_id, &alias, 2)?;
9740 let RepositoryStructure::Git(replacement_repository) =
9741 projectatlas_fs::worktree::discover_repository_structure(&primary)?
9742 else {
9743 return Err(io::Error::other("replacement Git fixture was not discovered").into());
9744 };
9745 let replacement_entry = replacement_repository
9746 .worktrees
9747 .iter()
9748 .find(|entry| match &entry.state {
9749 GitWorktreeState::Active {
9750 root: candidate, ..
9751 } => candidate == &root,
9752 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
9753 })
9754 .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
9755 let replacement_alias = WorktreeAlias::parse("replacement")?;
9756 let replacement_registration = control.register_worktree(
9757 &replacement_alias,
9758 &replacement_repository.common_directory,
9759 &replacement_entry.administrative_directory,
9760 &git_administrative_identity(&replacement_entry.administrative_directory)?,
9761 &root,
9762 None,
9763 3,
9764 )?;
9765 let preserved_target = root.join(".projectatlas-concurrent-binding");
9766 fs::rename(root.join(".projectatlas"), &preserved_target)?;
9767 let concurrent_binding = synchronize_registered_worktree_usage_with_catalog_validation(
9768 &control_database,
9769 &primary,
9770 control.project_instance_id()?,
9771 |_, _| Ok(()),
9772 || {
9773 fs::rename(&preserved_target, root.join(".projectatlas"))?;
9774 control.bind_worktree_project(
9775 replacement_registration.registration_id,
9776 &replacement_alias,
9777 &root,
9778 replacement_project,
9779 )?;
9780 Ok(())
9781 },
9782 |_| Ok(()),
9783 );
9784 require_eq(
9785 &concurrent_binding.as_ref().is_err_and(|error| {
9786 error
9787 .to_string()
9788 .contains("catalog changed during aggregate synchronization")
9789 }),
9790 &true,
9791 "concurrent unbound-to-bound catalog rejection",
9792 )?;
9793 require_eq(
9794 &control
9795 .worktree_registration(&replacement_alias)?
9796 .project_instance_id,
9797 &Some(replacement_project),
9798 "concurrently committed project binding",
9799 )
9800 }
9801
9802 #[test]
9803 fn worker_pools_respect_work_cardinality_and_runtime_ceiling() {
9804 for (work_items, max_workers, expected) in [
9805 (0, 16, 0),
9806 (1, 16, 1),
9807 (8, 16, 8),
9808 (64, 16, 16),
9809 (64, usize::MAX, INDEX_WORKER_SAFE_CEILING),
9810 (8, 0, 1),
9811 ] {
9812 assert_eq!(
9813 worker_count_for_work(work_items, max_workers),
9814 expected,
9815 "work_items={work_items}, max_workers={max_workers}"
9816 );
9817 }
9818 }
9819
9820 #[test]
9821 fn staged_classifications_prefer_registry_then_bounded_text_evidence() {
9822 let file = |path: &str, language: Option<&str>| Node {
9823 path: path.to_string(),
9824 kind: NodeKind::File,
9825 parent_path: None,
9826 extension: None,
9827 language: language.map(str::to_string),
9828 size_bytes: Some(1),
9829 mtime_ns: Some(1),
9830 content_hash: Some("hash".to_string()),
9831 };
9832 let nodes = vec![
9833 file("docs/guide.md", Some("markdown")),
9834 file("notes", None),
9835 file("blob", None),
9836 ];
9837 let rows = vec![
9838 TextIndexRow {
9839 path: "docs/guide.md".to_string(),
9840 text: None,
9841 reason: TextIndexSkipReason::BinaryOrNonUtf8,
9842 },
9843 TextIndexRow {
9844 path: "notes".to_string(),
9845 text: Some(IndexedFileText {
9846 path: "notes".to_string(),
9847 content_hash: Some("hash".to_string()),
9848 byte_count: 1,
9849 line_count: 1,
9850 content: "x".to_string(),
9851 }),
9852 reason: TextIndexSkipReason::Indexed,
9853 },
9854 TextIndexRow {
9855 path: "blob".to_string(),
9856 text: None,
9857 reason: TextIndexSkipReason::BinaryOrNonUtf8,
9858 },
9859 ];
9860
9861 assert_eq!(
9862 stage_file_content_classifications(&nodes, &rows),
9863 vec![
9864 FileContentClassification {
9865 path: "docs/guide.md".to_string(),
9866 classification:
9867 projectatlas_core::language::ContentClassification::Documentation,
9868 },
9869 FileContentClassification {
9870 path: "notes".to_string(),
9871 classification: projectatlas_core::language::ContentClassification::OtherText,
9872 },
9873 FileContentClassification {
9874 path: "blob".to_string(),
9875 classification: projectatlas_core::language::ContentClassification::Opaque,
9876 },
9877 ]
9878 );
9879 }
9880
9881 #[test]
9882 fn classified_navigation_capability_is_closed_and_directional() {
9883 let report = classified_navigation_capabilities();
9884 assert_eq!(
9885 report.classifications,
9886 [
9887 ContentClassification::Source,
9888 ContentClassification::Documentation,
9889 ContentClassification::ConfigurationData,
9890 ContentClassification::OtherText,
9891 ContentClassification::Opaque,
9892 ]
9893 );
9894 assert_eq!(
9895 report.selections,
9896 [
9897 ContentSelection::Source,
9898 ContentSelection::Documentation,
9899 ContentSelection::Both,
9900 ]
9901 );
9902 assert_eq!(report.document_relation, "extended:documents");
9903 assert_eq!(report.inbound_document_view, "documented_by");
9904 }
9905
9906 #[test]
9907 fn settings_publication_identity_rejects_mixed_snapshots() {
9908 let fingerprint = "a".repeat(64);
9909 let diagnostic = DatabasePublicationReport {
9910 state: IndexPublicationState::Complete,
9911 contract_fingerprint: Some(fingerprint.clone()),
9912 contract_fingerprint_state: DatabasePublicationContractState::Valid,
9913 generation: IndexGeneration::new(4),
9914 };
9915 let matching = IndexPublication {
9916 state: IndexPublicationState::Complete,
9917 contract_fingerprint: Some(fingerprint),
9918 generation: IndexGeneration::new(4),
9919 };
9920 assert!(settings_publication_matches(
9921 Some(&diagnostic),
9922 Some(&matching)
9923 ));
9924
9925 let next_generation = IndexPublication {
9926 generation: IndexGeneration::new(5),
9927 ..matching.clone()
9928 };
9929 assert!(!settings_publication_matches(
9930 Some(&diagnostic),
9931 Some(&next_generation)
9932 ));
9933
9934 let invalid = DatabasePublicationReport {
9935 contract_fingerprint: None,
9936 contract_fingerprint_state: DatabasePublicationContractState::Invalid,
9937 ..diagnostic
9938 };
9939 assert!(!settings_publication_matches(
9940 Some(&invalid),
9941 Some(&matching)
9942 ));
9943 }
9944
9945 #[cfg(unix)]
9946 #[test]
9947 fn settings_report_uses_native_db_identity_when_display_is_unavailable()
9948 -> Result<(), Box<dyn Error>> {
9949 use std::ffi::OsString;
9950 use std::os::unix::ffi::OsStringExt;
9951
9952 let temp = tempfile::tempdir()?;
9953 let raw_name = OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
9954 let root = temp.path().join(&raw_name);
9955 let database = root.join(".projectatlas/projectatlas.db");
9956 fs::create_dir_all(
9957 database
9958 .parent()
9959 .ok_or_else(|| io::Error::other("raw-root database has no parent"))?,
9960 )?;
9961 drop(AtlasStore::open_for_project(&database, &root)?);
9962
9963 let report = build_settings_report(&database, None, OutputFormat::Json)?;
9964 require_eq(
9965 &report.root_detection_source,
9966 &"db".to_string(),
9967 "native database root detection source",
9968 )?;
9969 require_eq(
9970 &report.repo_root,
9971 &None,
9972 "unavailable native database root display",
9973 )?;
9974 let serialized = serde_json::to_string(&report)?;
9975 require_eq(
9976 &serialized.contains('\u{fffd}'),
9977 &false,
9978 "serialized settings fabricated a replacement root",
9979 )?;
9980 Ok(())
9981 }
9982
9983 #[cfg(windows)]
9984 #[test]
9985 fn lossless_native_path_display_preserves_absolute_volume_guid_paths()
9986 -> Result<(), Box<dyn Error>> {
9987 let volume = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\file";
9988 let volume_display = lossless_native_path_display(Path::new(volume))
9989 .ok_or_else(|| io::Error::other("volume-GUID path was not UTF-8"))?;
9990 require_eq(
9991 &volume_display,
9992 &volume.to_string(),
9993 "lossless volume-GUID display",
9994 )?;
9995 require_eq(
9996 &Path::new(&volume_display).is_absolute(),
9997 &true,
9998 "volume-GUID display remained absolute",
9999 )?;
10000
10001 let drive = lossless_native_path_display(Path::new(r"\\?\C:\repo\file"))
10002 .ok_or_else(|| io::Error::other("extended drive path was not UTF-8"))?;
10003 require_eq(
10004 &drive,
10005 &"C:/repo/file".to_string(),
10006 "normalized extended drive display",
10007 )?;
10008 require_eq(
10009 &Path::new(&drive).is_absolute(),
10010 &true,
10011 "extended drive display remained absolute",
10012 )?;
10013
10014 let unc = lossless_native_path_display(Path::new(r"\\?\UNC\server\share\repo\file"))
10015 .ok_or_else(|| io::Error::other("extended UNC path was not UTF-8"))?;
10016 require_eq(
10017 &unc,
10018 &"//server/share/repo/file".to_string(),
10019 "normalized extended UNC display",
10020 )?;
10021 require_eq(
10022 &Path::new(&unc).is_absolute(),
10023 &true,
10024 "extended UNC display remained absolute",
10025 )?;
10026 Ok(())
10027 }
10028
10029 #[cfg(windows)]
10030 #[test]
10031 fn settings_report_preserves_verbatim_native_root_projection() -> Result<(), Box<dyn Error>> {
10032 let temp = tempfile::tempdir()?;
10033 let base = temp
10034 .path()
10035 .to_str()
10036 .ok_or("temporary directory was not UTF-8")?;
10037 let long_component = "a".repeat(220);
10038 let root = PathBuf::from(format!(r"\\?\{base}\{long_component}"));
10039 fs::create_dir(&root)?;
10040 let database = root.join(".projectatlas/projectatlas.db");
10041 fs::create_dir_all(
10042 database
10043 .parent()
10044 .ok_or("verbatim settings database has no parent")?,
10045 )?;
10046 drop(AtlasStore::open_for_project(&database, &root)?);
10047
10048 let config = temp.path().join("verbatim-settings-config.toml");
10049 fs::write(
10050 &config,
10051 format!(
10052 "[project]\nroot = {}\n",
10053 serde_json::to_string(
10054 &root
10055 .to_str()
10056 .ok_or("verbatim root was not UTF-8")?
10057 .to_owned()
10058 )?
10059 ),
10060 )?;
10061 let expected = CanonicalProjectRoot::from_path(&root)?.display_string()?;
10062 if !expected.starts_with(r"\\?\") {
10063 return Err("verbatim root lost its extended prefix".into());
10064 }
10065
10066 let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
10067 require_eq(
10068 &report.repo_root,
10069 &Some(expected.clone()),
10070 "JSON settings verbatim root",
10071 )?;
10072 let json = serde_json::to_value(&report)?;
10073 require_eq(
10074 &json.get("repo_root"),
10075 &Some(&Value::String(expected.clone())),
10076 "JSON settings verbatim root field",
10077 )?;
10078 let toon = crate::render_settings_report(&report);
10079 let toon_value: Value = toon_format::decode_default(&toon)?;
10080 require_eq(
10081 &toon_value.pointer("/settings/repo_root"),
10082 &Some(&Value::String(expected)),
10083 "TOON settings verbatim root field",
10084 )?;
10085 Ok(())
10086 }
10087
10088 #[cfg(windows)]
10089 #[test]
10090 fn settings_report_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
10091 let temp = tempfile::tempdir()?;
10092 let original = temp.path().join("CaseOnlyRoot");
10093 let staging = temp.path().join("CaseOnlyRootStaging");
10094 let renamed = temp.path().join("caseonlyroot");
10095 fs::create_dir(&original)?;
10096 let database = original.join(".projectatlas/projectatlas.db");
10097 fs::create_dir_all(
10098 database
10099 .parent()
10100 .ok_or_else(|| io::Error::other("case-only database has no parent"))?,
10101 )?;
10102 drop(AtlasStore::open_for_project(&database, &original)?);
10103
10104 fs::rename(&original, &staging)?;
10105 fs::rename(&staging, &renamed)?;
10106 let renamed_database = renamed.join(".projectatlas/projectatlas.db");
10107 let config = temp.path().join("case-only-config.toml");
10108 fs::write(
10109 &config,
10110 format!(
10111 "[project]\nroot = {}\n",
10112 serde_json::to_string(&renamed.to_string_lossy())?
10113 ),
10114 )?;
10115
10116 let report = build_settings_report(&renamed_database, Some(&config), OutputFormat::Json)?;
10117 require_eq(
10118 &report.root_verified,
10119 &true,
10120 "settings accepted case-only root rename",
10121 )?;
10122 require_eq(
10123 &report.root_mismatches.is_empty(),
10124 &true,
10125 "case-only root rename mismatch diagnostics",
10126 )?;
10127 let json = serde_json::to_value(&report)?;
10128 require_eq(
10129 &json.get("root_verified"),
10130 &Some(&Value::Bool(true)),
10131 "JSON settings root verification",
10132 )?;
10133 let toon = crate::render_settings_report(&report);
10134 let toon_value: Value = toon_format::decode_default(&toon)?;
10135 require_eq(
10136 &toon_value.pointer("/settings/root_verified"),
10137 &Some(&Value::Bool(true)),
10138 "TOON settings root verification",
10139 )?;
10140 Ok(())
10141 }
10142
10143 #[cfg(windows)]
10144 #[test]
10145 fn settings_report_rejects_case_sensitive_sibling() -> Result<(), Box<dyn Error>> {
10146 let temp = tempfile::tempdir()?;
10147 let parent = temp.path().join("case-sensitive-parent");
10148 fs::create_dir(&parent)?;
10149 let enabled = StdCommand::new("fsutil")
10150 .args(["file", "SetCaseSensitiveInfo"])
10151 .arg(&parent)
10152 .arg("enable")
10153 .status()
10154 .is_ok_and(|status| status.success());
10155 if !enabled {
10156 return Ok(());
10157 }
10158
10159 let stored_root = parent.join("Repo");
10160 let selected_root = parent.join("repo");
10161 fs::create_dir(&stored_root)?;
10162 fs::create_dir(&selected_root)?;
10163 let database = stored_root.join(".projectatlas/projectatlas.db");
10164 fs::create_dir_all(
10165 database
10166 .parent()
10167 .ok_or_else(|| io::Error::other("case-sensitive database has no parent"))?,
10168 )?;
10169 drop(AtlasStore::open_for_project(&database, &stored_root)?);
10170 let config = temp.path().join("case-sensitive-config.toml");
10171 fs::write(
10172 &config,
10173 format!(
10174 "[project]\nroot = {}\n",
10175 serde_json::to_string(&selected_root.to_string_lossy())?
10176 ),
10177 )?;
10178
10179 let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
10180 require_eq(
10181 &report.root_verified,
10182 &false,
10183 "settings rejected case-sensitive sibling",
10184 )?;
10185 require_eq(
10186 &report.root_mismatches.is_empty(),
10187 &false,
10188 "case-sensitive sibling mismatch diagnostics",
10189 )?;
10190 let json = serde_json::to_value(&report)?;
10191 require_eq(
10192 &json.get("root_verified"),
10193 &Some(&Value::Bool(false)),
10194 "JSON case-sensitive sibling verification",
10195 )?;
10196 let toon = crate::render_settings_report(&report);
10197 let toon_value: Value = toon_format::decode_default(&toon)?;
10198 require_eq(
10199 &toon_value.pointer("/settings/root_verified"),
10200 &Some(&Value::Bool(false)),
10201 "TOON case-sensitive sibling verification",
10202 )?;
10203 Ok(())
10204 }
10205
10206 #[cfg(windows)]
10207 #[test]
10208 fn freshness_and_symbol_build_revalidate_case_only_root_without_sibling_mutation()
10209 -> Result<(), Box<dyn Error>> {
10210 let temp = tempfile::tempdir()?;
10211 let original = temp.path().join("CaseOnlyRuntimeRoot");
10212 let staging = temp.path().join("CaseOnlyRuntimeRootStaging");
10213 let renamed = temp.path().join("caseonlyruntimeroot");
10214 fs::create_dir(&original)?;
10215 fs::write(original.join("lib.rs"), "pub fn runtime_root() {}\n")?;
10216 let database = original.join(".projectatlas/projectatlas.db");
10217 fs::create_dir_all(
10218 database
10219 .parent()
10220 .ok_or_else(|| io::Error::other("case-only runtime database has no parent"))?,
10221 )?;
10222 let original_plan = ScanRuntimePlan::for_path(None, &original, None)?;
10223 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10224 let mut original_store = open_atlas_store_for_project(&database, &original)?;
10225 run_scan_pipeline(&mut original_store, &original_plan, &symbol_options)?;
10226 let initial_project = original_store
10227 .project_instance_id()?
10228 .ok_or_else(|| io::Error::other("case-only runtime project identity is missing"))?;
10229 drop(original_store);
10230
10231 fs::rename(&original, &staging)?;
10232 fs::rename(&staging, &renamed)?;
10233 let renamed_database = renamed.join(".projectatlas/projectatlas.db");
10234 let fresh = open_fresh_atlas_store_for_project(&renamed_database, &renamed, None)?;
10235 require_eq(
10236 &fresh.project_instance_id()?,
10237 &Some(initial_project),
10238 "fresh read after case-only root rename",
10239 )?;
10240 drop(fresh);
10241
10242 let renamed_plan = ScanRuntimePlan::for_path(None, &renamed, None)?;
10243 let mut renamed_store = open_atlas_store_for_project(&renamed_database, &renamed)?;
10244 run_symbol_build_pipeline(&mut renamed_store, &renamed_plan, &symbol_options, None)?;
10245 require_eq(
10246 &renamed_store.project_instance_id()?,
10247 &Some(initial_project),
10248 "symbol build after case-only root rename",
10249 )?;
10250 drop(renamed_store);
10251
10252 let case_sensitive_parent = temp.path().join("runtime-case-sensitive-parent");
10253 fs::create_dir(&case_sensitive_parent)?;
10254 let enabled = StdCommand::new("fsutil")
10255 .args(["file", "SetCaseSensitiveInfo"])
10256 .arg(&case_sensitive_parent)
10257 .arg("enable")
10258 .status()
10259 .is_ok_and(|status| status.success());
10260 if !enabled {
10261 return Ok(());
10262 }
10263 let stored_root = case_sensitive_parent.join("Repo");
10264 let selected_root = case_sensitive_parent.join("repo");
10265 fs::create_dir(&stored_root)?;
10266 fs::create_dir(&selected_root)?;
10267 fs::write(stored_root.join("lib.rs"), "pub fn stored_root() {}\n")?;
10268 let sibling_database = stored_root.join(".projectatlas/projectatlas.db");
10269 fs::create_dir_all(
10270 sibling_database
10271 .parent()
10272 .ok_or_else(|| io::Error::other("case-sensitive runtime database has no parent"))?,
10273 )?;
10274 let sibling_plan = ScanRuntimePlan::for_path(None, &stored_root, None)?;
10275 let mut sibling_store = open_atlas_store_for_project(&sibling_database, &stored_root)?;
10276 run_scan_pipeline(&mut sibling_store, &sibling_plan, &symbol_options)?;
10277 drop(sibling_store);
10278
10279 let sidecar_bytes = |path: &Path| -> Result<Vec<Option<Vec<u8>>>, Box<dyn Error>> {
10280 [
10281 path.to_path_buf(),
10282 db_sidecar_path(path, "wal"),
10283 db_sidecar_path(path, "shm"),
10284 db_sidecar_path(path, "journal"),
10285 ]
10286 .into_iter()
10287 .map(|candidate| match fs::read(candidate) {
10288 Ok(bytes) => Ok(Some(bytes)),
10289 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
10290 Err(error) => Err(error.into()),
10291 })
10292 .collect()
10293 };
10294 let durable_state = |path: &Path| -> Result<
10295 (
10296 Option<CanonicalProjectRoot>,
10297 Option<ProjectInstanceId>,
10298 Option<IndexPublication>,
10299 ),
10300 Box<dyn Error>,
10301 > {
10302 let store = AtlasStore::open_read_only(path)?;
10303 Ok((
10304 store.project_root_identity()?,
10305 store.project_instance_id()?,
10306 store.index_publication()?,
10307 ))
10308 };
10309
10310 drop(AtlasStore::open_read_only(&sibling_database)?);
10311 let before_sidecars = sidecar_bytes(&sibling_database)?;
10312 let before_state = durable_state(&sibling_database)?;
10313 let Err(CliError::ProjectMismatch(_)) =
10314 open_fresh_atlas_store_for_project(&sibling_database, &selected_root, None)
10315 else {
10316 return Err(io::Error::other("case-sensitive sibling was admitted").into());
10317 };
10318 let after_sidecars = sidecar_bytes(&sibling_database)?;
10319 let after_state = durable_state(&sibling_database)?;
10320 require_eq(
10321 &after_sidecars,
10322 &before_sidecars,
10323 "case-sensitive sibling sidecar state",
10324 )?;
10325 require_eq(
10326 &after_state,
10327 &before_state,
10328 "case-sensitive sibling durable state",
10329 )?;
10330 Ok(())
10331 }
10332
10333 #[test]
10334 fn supplied_language_controls_symbol_candidate_owner() {
10335 for path in ["Cargo.toml", "src/App.vue", "scripts/Get-Atlas.ps1"] {
10336 assert!(!is_symbol_candidate(path, Some("toon")), "{path}");
10337 }
10338 assert!(is_symbol_candidate("data/report.toon", Some("rust")));
10339 assert!(is_symbol_candidate(
10340 "data/report.toon",
10341 Some("cargo-manifest")
10342 ));
10343 assert!(is_symbol_candidate("data/report.toon", Some("vue")));
10344 assert!(is_symbol_candidate("data/report.toon", Some("powershell")));
10345 assert!(!is_symbol_candidate("data/report.toon", Some("toon")));
10346 }
10347
10348 #[test]
10349 fn optional_catalog_symbol_work_requires_effective_admission() {
10350 assert!(is_symbol_candidate("scripts/report.awk", Some("awk")));
10351 assert!(!is_symbol_candidate_for_admission(
10352 "scripts/report.awk",
10353 Some("awk"),
10354 false,
10355 ));
10356 assert!(is_symbol_candidate_for_admission(
10357 "scripts/report.awk",
10358 Some("awk"),
10359 true,
10360 ));
10361 assert!(is_symbol_candidate_for_admission(
10362 "src/lib.rs",
10363 Some("rust"),
10364 false,
10365 ));
10366 }
10367
10368 #[test]
10369 fn missing_language_preserves_specialized_symbol_candidate_inference() {
10370 for path in [
10371 "Cargo.toml",
10372 "Cargo.lock",
10373 "src/App.vue",
10374 "src/App.VUE",
10375 "scripts/Get-Atlas.ps1",
10376 "scripts/Atlas.psm1",
10377 "scripts/Atlas.psd1",
10378 ] {
10379 assert!(is_symbol_candidate(path, None), "{path}");
10380 }
10381 assert!(!is_symbol_candidate("data/report.toon", None));
10382 }
10383
10384 #[test]
10385 fn rejected_database_location_does_not_create_or_change_database() -> Result<(), Box<dyn Error>>
10386 {
10387 let temp = tempfile::tempdir()?;
10388 let root = temp.path().join("repository");
10389 fs::create_dir_all(&root)?;
10390
10391 for uncertain in [false, true] {
10392 let database_parent = temp
10393 .path()
10394 .join(if uncertain {
10395 "uncertain"
10396 } else {
10397 "unsupported"
10398 })
10399 .join("nested");
10400 let database = database_parent.join("projectatlas.db");
10401 let rejected_path = database.clone();
10402 let result = open_atlas_store_for_project_with_location_validator(
10403 &database,
10404 &root,
10405 move |_path| {
10406 if uncertain {
10407 Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
10408 path: rejected_path,
10409 mount_point: None,
10410 filesystem_type: None,
10411 reason: "injected filesystem uncertainty".to_string(),
10412 })
10413 } else {
10414 Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
10415 path: rejected_path,
10416 mount_point: None,
10417 filesystem_type: Some("nfs".to_string()),
10418 })
10419 }
10420 },
10421 );
10422 let rejected = matches!(
10423 result,
10424 Err(CliError::Db(
10425 projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
10426 | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
10427 ))
10428 );
10429 require_eq(&rejected, &true, "typed location rejection")?;
10430 require_eq(
10431 &database_parent.exists(),
10432 &false,
10433 "rejected database parent absence",
10434 )?;
10435 require_eq(&database.exists(), &false, "rejected database absence")?;
10436
10437 let existing_database = temp.path().join(if uncertain {
10438 "uncertain-existing.db"
10439 } else {
10440 "unsupported-existing.db"
10441 });
10442 let original_bytes = b"existing database bytes stay untouched";
10443 fs::write(&existing_database, original_bytes)?;
10444 let rejected_path = existing_database.clone();
10445 let existing_result = open_atlas_store_for_project_with_location_validator(
10446 &existing_database,
10447 &root,
10448 move |_path| {
10449 if uncertain {
10450 Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
10451 path: rejected_path,
10452 mount_point: None,
10453 filesystem_type: None,
10454 reason: "injected filesystem uncertainty".to_string(),
10455 })
10456 } else {
10457 Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
10458 path: rejected_path,
10459 mount_point: None,
10460 filesystem_type: Some("nfs".to_string()),
10461 })
10462 }
10463 },
10464 );
10465 require_eq(
10466 &matches!(
10467 existing_result,
10468 Err(CliError::Db(
10469 projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
10470 | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
10471 ))
10472 ),
10473 &true,
10474 "typed existing location rejection",
10475 )?;
10476 require_eq(
10477 &fs::read(&existing_database)?,
10478 &original_bytes.to_vec(),
10479 "rejected existing database bytes",
10480 )?;
10481 }
10482 Ok(())
10483 }
10484
10485 #[test]
10486 fn operation_deadline_starts_with_default_or_shorter_explicit_timeout() {
10487 for (timeout_seconds, expected) in [
10488 (None, DEFAULT_INDEX_WORK_TIMEOUT),
10489 (Some(1), Duration::from_secs(1)),
10490 (
10491 Some(DEFAULT_INDEX_WORK_TIMEOUT.as_secs() + 1),
10492 DEFAULT_INDEX_WORK_TIMEOUT,
10493 ),
10494 ] {
10495 let options = SymbolBuildOptions::new(1_024, Some(1), timeout_seconds);
10496 let control = index_work_control(&options);
10497 assert_eq!(
10498 control
10499 .deadline()
10500 .map(|deadline| deadline.duration_since(control.started_at())),
10501 Some(expected)
10502 );
10503 }
10504 }
10505
10506 #[test]
10507 fn normal_read_refresh_delta_enforces_path_and_byte_budgets() -> Result<(), Box<dyn Error>> {
10508 let temp = tempfile::tempdir()?;
10509 let node = |path: String, size_bytes: u64| Node {
10510 path,
10511 kind: NodeKind::File,
10512 parent_path: None,
10513 extension: Some(".rs".to_string()),
10514 language: Some("rust".to_string()),
10515 size_bytes: Some(size_bytes),
10516 mtime_ns: Some(1),
10517 content_hash: Some("current-content".to_string()),
10518 };
10519 let path_bounded_nodes = (0..=NORMAL_READ_REFRESH_MAX_PATHS)
10520 .map(|index| node(format!("src/file_{index}.rs"), 1))
10521 .collect::<Vec<_>>();
10522 let path_delta = source_node_delta(temp.path(), &path_bounded_nodes, &[])
10523 .ok_or_else(|| io::Error::other("path-bounded freshness delta was missing"))?;
10524 require_eq(
10525 &path_delta.report.scope,
10526 &IndexRefreshScope::Full,
10527 "path-bounded refresh scope",
10528 )?;
10529 require_eq(
10530 &path_delta.report.changed,
10531 &(NORMAL_READ_REFRESH_MAX_PATHS + 1),
10532 "path-bounded change count",
10533 )?;
10534 require_eq(
10535 &path_delta.report.sample_paths.len(),
10536 &INDEX_FRESHNESS_SAMPLE_LIMIT,
10537 "bounded freshness sample",
10538 )?;
10539
10540 let byte_bounded_nodes = vec![node(
10541 "src/large.rs".to_string(),
10542 NORMAL_READ_REFRESH_MAX_BYTES + 1,
10543 )];
10544 let byte_delta = source_node_delta(temp.path(), &byte_bounded_nodes, &[])
10545 .ok_or_else(|| io::Error::other("byte-bounded freshness delta was missing"))?;
10546 require_eq(
10547 &byte_delta.report.scope,
10548 &IndexRefreshScope::Full,
10549 "byte-bounded refresh scope",
10550 )?;
10551 Ok(())
10552 }
10553
10554 #[test]
10555 fn controlled_freshness_plan_uses_the_callers_cancellation() -> Result<(), Box<dyn Error>> {
10556 let temp = tempfile::tempdir()?;
10557 let root = temp.path().join("repo");
10558 fs::create_dir(&root)?;
10559 fs::write(root.join("lib.rs"), "pub fn indexed() {}\n")?;
10560 let db_path = root.join(".projectatlas").join("projectatlas.db");
10561 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
10562 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10563 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
10564 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
10565 drop(store);
10566
10567 let invalid_config = root.join("invalid-config.toml");
10568 fs::write(&invalid_config, "[invalid")?;
10569 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10570 control.cancel();
10571 let result = open_fresh_atlas_store_for_project_controlled(
10572 &db_path,
10573 &root,
10574 Some(&invalid_config),
10575 &control,
10576 );
10577 if !matches!(
10578 result,
10579 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
10580 stage: IndexWorkStage::Publication,
10581 }))
10582 ) {
10583 return Err(io::Error::other(
10584 "controlled freshness parsed policy outside the caller cancellation boundary",
10585 )
10586 .into());
10587 }
10588 Ok(())
10589 }
10590
10591 #[test]
10592 fn automatic_read_refresh_returns_typed_state_when_writer_is_unavailable()
10593 -> Result<(), Box<dyn Error>> {
10594 let temp = tempfile::tempdir()?;
10595 let root = temp.path().join("repo");
10596 fs::create_dir(&root)?;
10597 let source_path = root.join("lib.rs");
10598 let indexed_source = "pub fn indexed() {}\n";
10599 let current_source = "pub fn current() {}\n";
10600 fs::write(&source_path, indexed_source)?;
10601 let db_path = root.join(".projectatlas").join("projectatlas.db");
10602 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
10603 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10604 let mut initial_store = open_atlas_store_for_project(&db_path, &plan.root)?;
10605 run_scan_pipeline(&mut initial_store, &plan, &symbol_options)?;
10606 let initial_generation = initial_store
10607 .index_publication()?
10608 .ok_or_else(|| io::Error::other("initial publication missing"))?
10609 .generation;
10610 drop(initial_store);
10611
10612 fs::write(&source_path, current_source)?;
10613 let mut blocking_writer = open_atlas_store_for_project(&db_path, &plan.root)?;
10614 let publication =
10615 blocking_writer.begin_index_publication(&plan.publication_contract_fingerprint())?;
10616 let refresh = open_fresh_atlas_store_for_project(&db_path, &plan.root, None);
10617 let Err(CliError::RefreshRequired(report)) = refresh else {
10618 return Err(io::Error::other(
10619 "contended automatic refresh did not return typed refresh_required",
10620 )
10621 .into());
10622 };
10623 require_eq(
10624 &report.scope,
10625 &IndexRefreshScope::Incremental,
10626 "contended automatic refresh scope",
10627 )?;
10628 require_eq(
10629 &report.reason,
10630 &IndexRefreshReason::SourceChanged,
10631 "contended automatic refresh reason",
10632 )?;
10633
10634 let last_valid = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
10635 require_eq(
10636 &last_valid
10637 .index_publication()?
10638 .ok_or_else(|| io::Error::other("last-valid publication missing"))?
10639 .generation,
10640 &initial_generation,
10641 "last-valid generation during contention",
10642 )?;
10643 require_eq(
10644 &last_valid
10645 .load_file_text("lib.rs")?
10646 .ok_or_else(|| io::Error::other("last-valid source text missing"))?
10647 .content,
10648 &indexed_source.to_string(),
10649 "last-valid source during contention",
10650 )?;
10651 drop(last_valid);
10652 drop(publication);
10653
10654 let repaired = open_fresh_atlas_store_for_project(&db_path, &plan.root, None)?;
10655 require_eq(
10656 &repaired
10657 .index_publication()?
10658 .ok_or_else(|| io::Error::other("repaired publication missing"))?
10659 .generation,
10660 &initial_generation
10661 .checked_next()
10662 .ok_or_else(|| io::Error::other("test generation overflow"))?,
10663 "repaired generation after contention",
10664 )?;
10665 require_eq(
10666 &repaired
10667 .load_file_text("lib.rs")?
10668 .ok_or_else(|| io::Error::other("repaired source text missing"))?
10669 .content,
10670 ¤t_source.to_string(),
10671 "current source after contention",
10672 )?;
10673 Ok(())
10674 }
10675
10676 #[test]
10677 fn fallback_retains_actual_source_provenance() -> Result<(), Box<dyn Error>> {
10678 for (language, content, parser) in [
10679 ("rust", "fn recovered() {}", ParserKind::TreeSitter),
10680 ("rust", "def recovered(): pass", ParserKind::Fallback),
10681 (
10682 "php",
10683 "<?php function recovered() {}",
10684 ParserKind::TreeSitter,
10685 ),
10686 ("php", "<?php\ndef recovered(): pass", ParserKind::Fallback),
10687 ] {
10688 let job = SymbolParseJob {
10689 path: format!("src/recovered.{language}"),
10690 native_path: PathBuf::new(),
10691 expected_content_hash: String::new(),
10692 language: Some(language.to_string()),
10693 fallback_summary: None,
10694 purpose_needs_suggestion: false,
10695 };
10696 let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
10697 &job,
10698 content,
10699 None,
10700 &SymbolBuildOptions::new(1_024, Some(1), None),
10701 &standalone_index_work_control(),
10702 ) else {
10703 return Err(io::Error::other("source provenance fixture did not parse").into());
10704 };
10705 require_eq(&parsed.graph.parser, &parser, "observed fact parser")?;
10706 require_eq(&parsed.source_parser, &parser, "observed source parser")?;
10707 require_eq(
10708 &parsed
10709 .graph
10710 .symbols
10711 .iter()
10712 .any(|symbol| symbol.name == "recovered"),
10713 &true,
10714 "recovered declaration",
10715 )?;
10716 }
10717 Ok(())
10718 }
10719
10720 #[test]
10721 fn partial_php_facts_keep_tree_sitter_source_provenance() -> Result<(), Box<dyn Error>> {
10722 let content = "<?php function run(): void { $callable(); helper(); }";
10723 let job = SymbolParseJob {
10724 path: "src/dynamic.php".to_string(),
10725 native_path: PathBuf::new(),
10726 expected_content_hash: String::new(),
10727 language: Some("php".to_string()),
10728 fallback_summary: None,
10729 purpose_needs_suggestion: false,
10730 };
10731 let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
10732 &job,
10733 content,
10734 None,
10735 &SymbolBuildOptions::new(1_024, Some(1), None),
10736 &standalone_index_work_control(),
10737 ) else {
10738 return Err(io::Error::other("partial PHP fixture did not parse").into());
10739 };
10740 require_eq(
10741 &parsed.graph.parser,
10742 &ParserKind::Fallback,
10743 "partial PHP fact parser",
10744 )?;
10745 require_eq(
10746 &parsed.source_parser,
10747 &ParserKind::TreeSitter,
10748 "partial PHP source parser",
10749 )?;
10750 require_eq(
10751 &parsed
10752 .graph
10753 .symbols
10754 .iter()
10755 .any(|symbol| symbol.name == "run"),
10756 &true,
10757 "partial PHP recovered symbol",
10758 )?;
10759 require_eq(
10760 &parsed.graph.relations.iter().any(|relation| {
10761 relation.kind == RelationKind::Calls && relation.target_name == "helper"
10762 }),
10763 &true,
10764 "partial PHP known call",
10765 )?;
10766 require_eq(
10767 &parsed.graph.relations.iter().all(|relation| {
10768 relation.kind != RelationKind::Calls || relation.target_name != "$callable"
10769 }),
10770 &true,
10771 "partial PHP dynamic call abstention",
10772 )?;
10773 Ok(())
10774 }
10775
10776 #[test]
10777 fn derived_source_readers_reject_bytes_outside_staged_hash() -> Result<(), Box<dyn Error>> {
10778 let temp = tempfile::tempdir()?;
10779 let path = temp.path().join("lib.rs");
10780 let staged_source = "fn first() {}\n";
10781 let changed_source = "fn other() {}\n";
10782 fs::write(&path, staged_source)?;
10783 let expected_content_hash = blake3::hash(staged_source.as_bytes()).to_hex().to_string();
10784 let node = Node {
10785 path: "lib.rs".to_string(),
10786 kind: NodeKind::File,
10787 parent_path: None,
10788 extension: Some(".rs".to_string()),
10789 language: Some("rust".to_string()),
10790 size_bytes: Some(staged_source.len() as u64),
10791 mtime_ns: Some(1),
10792 content_hash: Some(expected_content_hash.clone()),
10793 };
10794 fs::write(&path, changed_source)?;
10795
10796 let text_result = indexed_file_texts_for_nodes(
10797 temp.path(),
10798 std::slice::from_ref(&node),
10799 TextIndexOptions::new(1_024),
10800 );
10801 let Err(CliError::RefreshRequired(details)) = text_result else {
10802 return Err(io::Error::other("text derivation accepted changed source bytes").into());
10803 };
10804 require_eq(
10805 &details.reason,
10806 &IndexRefreshReason::SourceChanged,
10807 "text source-change reason",
10808 )?;
10809 require_eq(
10810 &details.sample_paths,
10811 &vec!["lib.rs".to_string()],
10812 "text source-change paths",
10813 )?;
10814
10815 let symbol_outcome = parse_symbol_job(
10816 &SymbolParseJob {
10817 path: node.path,
10818 native_path: path,
10819 expected_content_hash,
10820 language: node.language,
10821 fallback_summary: None,
10822 purpose_needs_suggestion: false,
10823 },
10824 &SymbolBuildOptions::new(1_024, Some(1), None),
10825 Instant::now(),
10826 );
10827 if !matches!(
10828 symbol_outcome,
10829 SymbolParseOutcome::SourceChanged { path } if path == "lib.rs"
10830 ) {
10831 return Err(io::Error::other("symbol derivation accepted changed source bytes").into());
10832 }
10833
10834 let bounded_path = temp.path().join("bounded.txt");
10835 fs::write(&bounded_path, "four")?;
10836 let bounded_node = Node {
10837 path: "bounded.txt".to_string(),
10838 kind: NodeKind::File,
10839 parent_path: None,
10840 extension: Some(".txt".to_string()),
10841 language: Some("text".to_string()),
10842 size_bytes: Some(4),
10843 mtime_ns: Some(1),
10844 content_hash: Some(blake3::hash(b"four").to_hex().to_string()),
10845 };
10846 let bounded_control = standalone_index_work_control();
10847 let bounded_result = indexed_file_texts_for_nodes_with_limit(
10848 temp.path(),
10849 &[bounded_node],
10850 TextIndexOptions::new(1_024),
10851 3,
10852 &bounded_control,
10853 );
10854 if !matches!(
10855 bounded_result,
10856 Err(CliError::IndexWork(
10857 IndexWorkFailure::ResourceLimitExceeded {
10858 stage: IndexWorkStage::TextIndex,
10859 resource: IndexWorkResource::TextBytes,
10860 limit: 3,
10861 observed: 4,
10862 }
10863 ))
10864 ) {
10865 return Err(io::Error::other("text staging accepted bytes beyond its limit").into());
10866 }
10867
10868 let bounded_symbol = parse_symbol_job(
10869 &SymbolParseJob {
10870 path: "bounded.txt".to_string(),
10871 native_path: bounded_path,
10872 expected_content_hash: blake3::hash(b"four").to_hex().to_string(),
10873 language: Some("text".to_string()),
10874 fallback_summary: None,
10875 purpose_needs_suggestion: false,
10876 },
10877 &SymbolBuildOptions::new(3, Some(1), None),
10878 Instant::now(),
10879 );
10880 if !matches!(
10881 bounded_symbol,
10882 SymbolParseOutcome::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10883 stage: IndexWorkStage::SymbolParsing,
10884 resource: IndexWorkResource::SourceBytes,
10885 limit: 3,
10886 observed: 4,
10887 })
10888 ) {
10889 return Err(io::Error::other("symbol read accepted bytes beyond its limit").into());
10890 }
10891 Ok(())
10892 }
10893
10894 #[test]
10895 fn parser_workers_reuse_structural_summaries_without_touching_approved_purpose()
10896 -> Result<(), Box<dyn Error>> {
10897 let temp = tempfile::tempdir()?;
10898 let path = temp.path().join("package.json");
10899 let content =
10900 r#"{"name":"demo","scripts":{"test":"vitest"},"dependencies":{"react":"1.0.0"}}"#;
10901 fs::write(&path, content)?;
10902 let node = Node {
10903 path: "package.json".to_string(),
10904 kind: NodeKind::File,
10905 parent_path: None,
10906 extension: Some(".json".to_string()),
10907 language: Some("json".to_string()),
10908 size_bytes: Some(content.len() as u64),
10909 mtime_ns: Some(1),
10910 content_hash: Some(blake3::hash(content.as_bytes()).to_hex().to_string()),
10911 };
10912 let mut store = AtlasStore::in_memory()?;
10913 store.replace_scan(std::slice::from_ref(&node))?;
10914 store.set_purpose(
10915 "package.json",
10916 "Own the JavaScript package manifest.",
10917 PurposeSource::Agent,
10918 )?;
10919 let text = refresh_text_index_for_nodes_with_rows(
10920 &mut store,
10921 temp.path(),
10922 std::slice::from_ref(&node),
10923 TextIndexOptions::new(1_024),
10924 )?;
10925 let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
10926 &SymbolParseJob {
10927 path: node.path.clone(),
10928 native_path: path,
10929 expected_content_hash: node
10930 .content_hash
10931 .clone()
10932 .ok_or_else(|| io::Error::other("fixture hash missing"))?,
10933 language: node.language.clone(),
10934 fallback_summary: None,
10935 purpose_needs_suggestion: false,
10936 },
10937 &SymbolBuildOptions::new(1_024, Some(1), None),
10938 Instant::now(),
10939 ) else {
10940 return Err(io::Error::other("package manifest did not parse").into());
10941 };
10942 require_eq(
10943 &parsed.summary_is_structural,
10944 &true,
10945 "parser-owned structural summary",
10946 )?;
10947 require_eq(
10948 &parsed.summary.as_str(),
10949 &"package manifest for demo with scripts test and 1 dependencies.",
10950 "structural content summary",
10951 )?;
10952 require_eq(
10953 &parsed.purpose_suggestion.is_none(),
10954 &true,
10955 "approved-purpose suggestion suppression",
10956 )?;
10957 let retained_bytes = symbol_parse_output_bytes(&parsed);
10958 let mut symbols = SymbolBuildStage {
10959 report: empty_symbol_build_report(),
10960 changes: vec![SymbolProjectionChange::Parsed(parsed)],
10961 retained_bytes,
10962 identity_admission: graph_projection::GraphIdentityAdmission::default(),
10963 };
10964 let protected_purpose_paths = HashSet::from(["package.json".to_string()]);
10965 let control = standalone_index_work_control();
10966 let structural = stage_structural_summaries_for_nodes_controlled(
10967 &store,
10968 std::slice::from_ref(&node),
10969 &text.rows,
10970 Some(&symbols),
10971 &protected_purpose_paths,
10972 1,
10973 &control,
10974 )?;
10975 require_eq(
10976 &structural.report.summarized,
10977 &1,
10978 "structural summary report",
10979 )?;
10980 require_eq(
10981 &structural.report.purpose_suggestions,
10982 &0,
10983 "structural purpose-suggestion report",
10984 )?;
10985 require_eq(
10986 &structural.changes.is_empty(),
10987 &true,
10988 "duplicate structural mutations",
10989 )?;
10990
10991 apply_symbol_build_stage(&mut store, &mut symbols, &control)?;
10992 apply_structural_summary_stage(&mut store, &structural, &control)?;
10993 let indexed = store
10994 .load_node_by_path("package.json")?
10995 .ok_or_else(|| io::Error::other("indexed package manifest missing"))?;
10996 require_eq(
10997 &indexed.summary.as_deref(),
10998 &Some("package manifest for demo with scripts test and 1 dependencies."),
10999 "persisted structural content summary",
11000 )?;
11001 require_eq(
11002 &indexed.purpose.purpose.as_deref(),
11003 &Some("Own the JavaScript package manifest."),
11004 "approved purpose text",
11005 )?;
11006 require_eq(
11007 &indexed.purpose.status,
11008 &PurposeStatus::Approved,
11009 "approved purpose status",
11010 )?;
11011 Ok(())
11012 }
11013
11014 #[test]
11015 fn symbol_build_clamps_file_bytes_and_bounds_all_published_output() -> Result<(), Box<dyn Error>>
11016 {
11017 let temp = tempfile::tempdir()?;
11018 fs::write(
11019 temp.path().join("lib.rs"),
11020 "pub fn first() { second(); }\nfn second() {}\n",
11021 )?;
11022 let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
11023 let mut store = AtlasStore::in_memory()?;
11024 store.replace_scan(&nodes)?;
11025 let options = SymbolBuildOptions::new(u64::MAX, Some(INDEX_WORKER_SAFE_CEILING), None);
11026 require_eq(
11027 &options.max_bytes,
11028 &MAX_SYMBOL_FILE_BYTES,
11029 "effective CLI/MCP symbol file limit",
11030 )?;
11031 let control = standalone_index_work_control();
11032 for resource in [
11033 IndexWorkResource::SymbolRows,
11034 IndexWorkResource::RelationRows,
11035 IndexWorkResource::OutputBytes,
11036 ] {
11037 if !matches!(
11038 checked_symbol_publication_usage(1, 1, 1, resource),
11039 Err(CliError::IndexWork(
11040 IndexWorkFailure::ResourceLimitExceeded { observed: 2, .. }
11041 ))
11042 ) {
11043 return Err(io::Error::other(format!(
11044 "symbol publication did not accumulate {resource}"
11045 ))
11046 .into());
11047 }
11048 }
11049 let cases = [
11050 (
11051 SymbolPublicationLimits {
11052 symbol_rows: 0,
11053 relation_rows: u64::MAX,
11054 output_bytes: u64::MAX,
11055 },
11056 IndexWorkResource::SymbolRows,
11057 ),
11058 (
11059 SymbolPublicationLimits {
11060 symbol_rows: u64::MAX,
11061 relation_rows: 0,
11062 output_bytes: u64::MAX,
11063 },
11064 IndexWorkResource::RelationRows,
11065 ),
11066 (
11067 SymbolPublicationLimits {
11068 symbol_rows: u64::MAX,
11069 relation_rows: u64::MAX,
11070 output_bytes: 0,
11071 },
11072 IndexWorkResource::OutputBytes,
11073 ),
11074 ];
11075 for (limits, expected_resource) in cases {
11076 let result = build_symbols_for_paths_with_limits(
11077 &mut store,
11078 temp.path(),
11079 &options,
11080 None,
11081 None,
11082 &control,
11083 limits,
11084 );
11085 if !matches!(
11086 result,
11087 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
11088 stage: IndexWorkStage::SymbolParsing,
11089 resource,
11090 ..
11091 })) if resource == expected_resource
11092 ) {
11093 return Err(io::Error::other(format!(
11094 "symbol publication did not enforce {expected_resource}"
11095 ))
11096 .into());
11097 }
11098 require_eq(
11099 &store.symbol_count_for_path("lib.rs")?,
11100 &0,
11101 "no over-limit symbol output persisted",
11102 )?;
11103 }
11104
11105 let mut oversized_node = nodes
11106 .iter()
11107 .find(|node| node.path == "lib.rs")
11108 .cloned()
11109 .ok_or_else(|| io::Error::other("oversized symbol fixture node missing"))?;
11110 oversized_node.size_bytes = Some(4);
11111 let clear_bytes = oversized_node.path.len() as u64
11112 + oversized_node.language.as_ref().map_or(0, String::len) as u64;
11113 let clear_options = SymbolBuildOptions::new(3, Some(1), None);
11114 let clear_limits = SymbolPublicationLimits {
11115 symbol_rows: u64::MAX,
11116 relation_rows: u64::MAX,
11117 output_bytes: clear_bytes.saturating_sub(1),
11118 };
11119 let clear_result = stage_symbols_for_nodes_with_limits(
11120 &store,
11121 temp.path(),
11122 #[cfg(feature = "optional-parser-supervisor")]
11123 &OptionalParserPackProjectSelection::Inactive,
11124 std::slice::from_ref(&oversized_node),
11125 &clear_options,
11126 None,
11127 None,
11128 &HashSet::new(),
11129 &control,
11130 clear_limits,
11131 );
11132 if !matches!(
11133 clear_result,
11134 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
11135 stage: IndexWorkStage::SymbolParsing,
11136 resource: IndexWorkResource::OutputBytes,
11137 limit,
11138 observed,
11139 })) if limit == clear_bytes.saturating_sub(1) && observed == clear_bytes
11140 ) {
11141 return Err(
11142 io::Error::other("symbol clear output bypassed its retained-byte limit").into(),
11143 );
11144 }
11145 let clear_stage = stage_symbols_for_nodes_with_limits(
11146 &store,
11147 temp.path(),
11148 #[cfg(feature = "optional-parser-supervisor")]
11149 &OptionalParserPackProjectSelection::Inactive,
11150 std::slice::from_ref(&oversized_node),
11151 &clear_options,
11152 None,
11153 None,
11154 &HashSet::new(),
11155 &control,
11156 SymbolPublicationLimits {
11157 output_bytes: clear_bytes,
11158 ..SymbolPublicationLimits::STANDARD
11159 },
11160 )?;
11161 require_eq(
11162 &clear_stage.retained_bytes,
11163 &clear_bytes,
11164 "retained symbol clear bytes",
11165 )?;
11166 if !matches!(
11167 clear_stage.changes.as_slice(),
11168 [SymbolProjectionChange::Clear { path, language }]
11169 if path == "lib.rs" && language.as_deref() == Some("rust")
11170 ) {
11171 return Err(
11172 io::Error::other("oversized symbol output did not retain one clear").into(),
11173 );
11174 }
11175
11176 let report = build_symbols_for_paths_with_limits(
11177 &mut store,
11178 temp.path(),
11179 &options,
11180 None,
11181 None,
11182 &control,
11183 SymbolPublicationLimits::STANDARD,
11184 )?;
11185 require_eq(&report.parsed, &1, "compatible bounded symbol build")?;
11186 require_eq(&report.max_workers, &1, "single-job symbol worker count")?;
11187 if report.symbols == 0 || report.relations == 0 {
11188 return Err(io::Error::other("bounded symbol build omitted parser output").into());
11189 }
11190 Ok(())
11191 }
11192
11193 #[test]
11194 fn markdown_parse_budget_counts_enclosing_heading_identity() -> Result<(), Box<dyn Error>> {
11195 let temp = tempfile::tempdir()?;
11196 let path = temp.path().join("guide.md");
11197 let content = "# Guide\n\n[target](target.md)\n";
11198 fs::write(&path, content)?;
11199 let SymbolParseOutcome::Parsed(mut parsed) = parse_symbol_job(
11200 &SymbolParseJob {
11201 path: "guide.md".to_string(),
11202 native_path: path,
11203 expected_content_hash: blake3::hash(content.as_bytes()).to_hex().to_string(),
11204 language: Some("markdown".to_string()),
11205 fallback_summary: None,
11206 purpose_needs_suggestion: false,
11207 },
11208 &SymbolBuildOptions::new(1_024, Some(1), None),
11209 Instant::now(),
11210 ) else {
11211 return Err(io::Error::other("Markdown fixture did not parse").into());
11212 };
11213 let enclosing_heading_bytes = parsed
11214 .markdown_facts
11215 .as_ref()
11216 .ok_or_else(|| io::Error::other("Markdown facts missing"))?
11217 .link_candidates
11218 .iter()
11219 .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
11220 .sum::<u64>();
11221 let retained_with_heading_owners = symbol_parse_output_bytes(&parsed);
11222 for candidate in &mut parsed
11223 .markdown_facts
11224 .as_mut()
11225 .ok_or_else(|| io::Error::other("Markdown facts missing"))?
11226 .link_candidates
11227 {
11228 candidate.enclosing_heading = None;
11229 }
11230 require_eq(
11231 &retained_with_heading_owners.saturating_sub(symbol_parse_output_bytes(&parsed)),
11232 &enclosing_heading_bytes,
11233 "parser output enclosing-heading bytes",
11234 )?;
11235 Ok(())
11236 }
11237
11238 #[test]
11239 fn purpose_import_skips_non_utf8_source_headers_but_keeps_authored_inputs_strict()
11240 -> Result<(), Box<dyn Error>> {
11241 let temp = tempfile::tempdir()?;
11242 let config_path = init_config_path(temp.path(), None);
11243 init_project_with_config(temp.path(), Some(&config_path))?;
11244 let config = load_atlas_config(Some(&config_path))?;
11245 fs::write(
11246 temp.path().join("binary.txt"),
11247 b"// Purpose: Must not be imported from binary source.\n\xff",
11248 )?;
11249 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11250 let nodes = scan_repo(&plan.root, &plan.scan_options)?;
11251 if !nodes.iter().any(|node| node.path == "binary.txt") {
11252 return Err(io::Error::other("binary source fixture was not scanned").into());
11253 }
11254 let snapshot =
11255 plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control())?;
11256 if snapshot
11257 .records
11258 .iter()
11259 .any(|record| record.path == "binary.txt")
11260 {
11261 return Err(io::Error::other("non-UTF-8 source header imported a purpose").into());
11262 }
11263
11264 fs::write(&config.map_path, [0xff])?;
11265 let strict =
11266 plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control());
11267 if !matches!(
11268 strict,
11269 Err(CliError::InvalidInput(message))
11270 if message.contains("purpose input is not valid UTF-8")
11271 ) {
11272 return Err(
11273 io::Error::other("non-UTF-8 authored purpose input was not rejected").into(),
11274 );
11275 }
11276 Ok(())
11277 }
11278
11279 #[test]
11280 fn purpose_import_inputs_observe_cancellation_limits_and_rollback() -> Result<(), Box<dyn Error>>
11281 {
11282 struct CancelAfterFirstRead {
11283 bytes: io::Cursor<Vec<u8>>,
11284 cancellation: IndexCancellation,
11285 reads: usize,
11286 }
11287
11288 impl Read for CancelAfterFirstRead {
11289 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
11290 let read = self.bytes.read(buffer)?;
11291 self.reads += 1;
11292 if self.reads == 1 {
11293 self.cancellation.cancel();
11294 }
11295 Ok(read)
11296 }
11297 }
11298
11299 let temp = tempfile::tempdir()?;
11300 let config_path = init_config_path(temp.path(), None);
11301 init_project_with_config(temp.path(), Some(&config_path))?;
11302 let fixture_config = load_atlas_config(Some(&config_path))?;
11303 fs::write(temp.path().join("lib.rs"), "fn imported() {}\n")?;
11304 fs::write(
11305 &fixture_config.map_path,
11306 "folders[1]:\n .,Controlled imported repository purpose\n",
11307 )?;
11308 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11309 let nodes = scan_repo(&plan.root, &plan.scan_options)?;
11310 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11311 let mut store = AtlasStore::in_memory()?;
11312 store.set_project_root(&plan.root)?;
11313 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11314 let publication_before = store
11315 .index_publication()?
11316 .ok_or_else(|| io::Error::other("controlled import publication missing"))?;
11317 let root_before = store
11318 .load_node_by_path(".")?
11319 .ok_or_else(|| io::Error::other("controlled import root missing"))?;
11320 let text_before = store
11321 .load_file_text("lib.rs")?
11322 .ok_or_else(|| io::Error::other("controlled import text missing"))?;
11323 let symbol_count_before = store.symbol_count()?;
11324 let relation_count_before = store.symbol_relation_count()?;
11325
11326 let config_bytes = fs::metadata(&config_path)?.len();
11327 let map_bytes = fs::metadata(&fixture_config.map_path)?.len();
11328 let nonsource_bytes = fs::metadata(&fixture_config.nonsource_files_path)?.len();
11329 let source_bytes = fs::metadata(temp.path().join("lib.rs"))?.len();
11330 let staged_input_bytes = config_bytes
11331 .saturating_add(map_bytes)
11332 .saturating_add(nonsource_bytes)
11333 .saturating_add(source_bytes);
11334 let complete_operation_bytes = config_bytes
11335 .saturating_add(staged_input_bytes)
11336 .saturating_add(config_bytes)
11337 .saturating_add(staged_input_bytes);
11338 let largest_complete_input = config_bytes.max(map_bytes).max(nonsource_bytes);
11339 let aggregate_limits = PurposeImportLimits {
11340 total_bytes: complete_operation_bytes,
11341 complete_file_bytes: largest_complete_input,
11342 header_bytes: source_bytes,
11343 records: 100,
11344 };
11345 let aggregate_control = standalone_index_work_control();
11346 let aggregate_plan = ScanRuntimePlan::for_path_controlled_with_limits(
11347 None,
11348 temp.path(),
11349 None,
11350 &aggregate_control,
11351 aggregate_limits,
11352 )?;
11353 let aggregate_nodes = scan_repo(&aggregate_plan.root, &aggregate_plan.scan_options)?;
11354 let aggregate_snapshot = aggregate_plan.purpose_import_snapshot_controlled_with_limits(
11355 &aggregate_nodes,
11356 &aggregate_control,
11357 aggregate_limits,
11358 )?;
11359 revalidate_index_publication_inputs_controlled_with_limits(
11360 &store,
11361 &aggregate_plan,
11362 Some(&aggregate_snapshot.fingerprint),
11363 &aggregate_control,
11364 aggregate_limits,
11365 )?;
11366
11367 let cumulative_limit = complete_operation_bytes.saturating_sub(1);
11368 let cumulative_limits = PurposeImportLimits {
11369 total_bytes: cumulative_limit,
11370 ..aggregate_limits
11371 };
11372 let cumulative_control = standalone_index_work_control();
11373 let cumulative_plan = ScanRuntimePlan::for_path_controlled_with_limits(
11374 None,
11375 temp.path(),
11376 None,
11377 &cumulative_control,
11378 cumulative_limits,
11379 )?;
11380 let cumulative_nodes = scan_repo(&cumulative_plan.root, &cumulative_plan.scan_options)?;
11381 let cumulative_snapshot = cumulative_plan.purpose_import_snapshot_controlled_with_limits(
11382 &cumulative_nodes,
11383 &cumulative_control,
11384 cumulative_limits,
11385 )?;
11386 let cumulative_result = revalidate_index_publication_inputs_controlled_with_limits(
11387 &store,
11388 &cumulative_plan,
11389 Some(&cumulative_snapshot.fingerprint),
11390 &cumulative_control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT),
11391 cumulative_limits,
11392 );
11393 if !matches!(
11394 cumulative_result,
11395 Err(CliError::IndexWork(
11396 IndexWorkFailure::ResourceLimitExceeded {
11397 stage: IndexWorkStage::Publication,
11398 resource: IndexWorkResource::PurposeBytes,
11399 limit,
11400 observed,
11401 }
11402 )) if limit == cumulative_limit && observed > limit
11403 ) {
11404 return Err(io::Error::other(
11405 "plan, staging, and revalidation readers did not share one purpose-byte budget",
11406 )
11407 .into());
11408 }
11409
11410 let control = standalone_index_work_control();
11411 let staged_snapshot = plan.purpose_import_snapshot_controlled(&nodes, &control)?;
11412 let small_limits = PurposeImportLimits {
11413 total_bytes: 64,
11414 complete_file_bytes: 64,
11415 header_bytes: 64,
11416 records: 100,
11417 };
11418 let initial_limit_control = standalone_index_work_control();
11419 let initial_limited = plan.purpose_import_snapshot_controlled_with_limits(
11420 &nodes,
11421 &initial_limit_control,
11422 small_limits,
11423 );
11424 if !matches!(
11425 initial_limited,
11426 Err(CliError::IndexWork(
11427 IndexWorkFailure::ResourceLimitExceeded {
11428 stage: IndexWorkStage::Publication,
11429 resource: IndexWorkResource::PurposeBytes,
11430 limit: 64,
11431 observed: 65,
11432 }
11433 ))
11434 ) {
11435 return Err(io::Error::other(
11436 "initial purpose snapshot exceeded limits without a typed failure",
11437 )
11438 .into());
11439 }
11440 let initial_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
11441 initial_cancel.cancel();
11442 if !matches!(
11443 plan.purpose_import_snapshot_controlled(&nodes, &initial_cancel),
11444 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11445 stage: IndexWorkStage::Publication,
11446 }))
11447 ) {
11448 return Err(io::Error::other(
11449 "initial purpose snapshot ignored operation cancellation",
11450 )
11451 .into());
11452 }
11453
11454 let contract_fingerprint = plan.publication_contract_fingerprint();
11455 let mut publication = store.begin_index_publication(&contract_fingerprint)?;
11456 publication.set_project_root(&plan.root)?;
11457 publication.replace_scan(&nodes)?;
11458 publication.set_purpose(".", "Uncommitted purpose input", PurposeSource::Imported)?;
11459 let publication_limit_control = standalone_index_work_control();
11460 let limited = revalidate_index_publication_inputs_controlled_with_limits(
11461 &publication,
11462 &plan,
11463 Some(&staged_snapshot.fingerprint),
11464 &publication_limit_control,
11465 small_limits,
11466 );
11467 if !matches!(
11468 limited,
11469 Err(CliError::IndexWork(
11470 IndexWorkFailure::ResourceLimitExceeded {
11471 stage: IndexWorkStage::Publication,
11472 resource: IndexWorkResource::PurposeBytes,
11473 limit: 64,
11474 observed: 65,
11475 }
11476 ))
11477 ) {
11478 return Err(io::Error::other(
11479 "publication purpose inputs exceeded limits without a typed failure",
11480 )
11481 .into());
11482 }
11483 drop(publication);
11484 require_eq(
11485 &store.index_publication()?,
11486 &Some(publication_before.clone()),
11487 "publication after bounded purpose-input rollback",
11488 )?;
11489 require_eq(
11490 &store.load_node_by_path(".")?,
11491 &Some(root_before.clone()),
11492 "authored purpose after bounded input rollback",
11493 )?;
11494 require_eq(
11495 &store.load_file_text("lib.rs")?,
11496 &Some(text_before.clone()),
11497 "indexed text after bounded input rollback",
11498 )?;
11499 require_eq(
11500 &store.symbol_count()?,
11501 &symbol_count_before,
11502 "symbols after bounded input rollback",
11503 )?;
11504 require_eq(
11505 &store.symbol_relation_count()?,
11506 &relation_count_before,
11507 "relations after bounded input rollback",
11508 )?;
11509
11510 let mut canceled_publication = store.begin_index_publication(&contract_fingerprint)?;
11511 canceled_publication.set_project_root(&plan.root)?;
11512 canceled_publication.replace_scan(&nodes)?;
11513 canceled_publication.set_purpose(
11514 ".",
11515 "Canceled uncommitted purpose input",
11516 PurposeSource::Imported,
11517 )?;
11518 let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
11519 late_cancel.cancel();
11520 let canceled = revalidate_index_publication_inputs_controlled(
11521 &canceled_publication,
11522 &plan,
11523 Some(&staged_snapshot.fingerprint),
11524 &late_cancel,
11525 );
11526 if !matches!(
11527 canceled,
11528 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11529 stage: IndexWorkStage::Publication,
11530 }))
11531 ) {
11532 return Err(io::Error::other(
11533 "late purpose-input revalidation ignored operation cancellation",
11534 )
11535 .into());
11536 }
11537 drop(canceled_publication);
11538 require_eq(
11539 &store.index_publication()?,
11540 &Some(publication_before),
11541 "publication after canceled purpose-input rollback",
11542 )?;
11543 require_eq(
11544 &store.load_node_by_path(".")?,
11545 &Some(root_before),
11546 "authored purpose after canceled input rollback",
11547 )?;
11548 require_eq(
11549 &store.load_file_text("lib.rs")?,
11550 &Some(text_before),
11551 "indexed text after canceled input rollback",
11552 )?;
11553 require_eq(
11554 &store.symbol_count()?,
11555 &symbol_count_before,
11556 "symbols after canceled input rollback",
11557 )?;
11558 require_eq(
11559 &store.symbol_relation_count()?,
11560 &relation_count_before,
11561 "relations after canceled input rollback",
11562 )?;
11563
11564 let record_limited = plan.purpose_import_snapshot_controlled_with_limits(
11565 &nodes,
11566 &control,
11567 PurposeImportLimits {
11568 records: 0,
11569 ..PurposeImportLimits::default()
11570 },
11571 );
11572 if !matches!(
11573 record_limited,
11574 Err(CliError::IndexWork(
11575 IndexWorkFailure::ResourceLimitExceeded {
11576 stage: IndexWorkStage::Publication,
11577 resource: IndexWorkResource::PurposeRecords,
11578 limit: 0,
11579 observed,
11580 }
11581 )) if observed > 0
11582 ) {
11583 return Err(io::Error::other("purpose record limit was not enforced").into());
11584 }
11585
11586 let cancellation = IndexCancellation::new();
11587 let cancel_control = IndexWorkControl::new(cancellation.clone(), None);
11588 let mut reader =
11589 PurposeInputReader::new(&plan, &cancel_control, PurposeImportLimits::default());
11590 let mut input = CancelAfterFirstRead {
11591 bytes: io::Cursor::new(vec![b'x'; CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2]),
11592 cancellation,
11593 reads: 0,
11594 };
11595 let canceled = reader.read_bytes(
11596 Path::new("controlled-purpose-input"),
11597 &mut input,
11598 u64::try_from(CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2).unwrap_or(u64::MAX),
11599 true,
11600 );
11601 if !matches!(
11602 canceled,
11603 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11604 stage: IndexWorkStage::Publication,
11605 }))
11606 ) {
11607 return Err(io::Error::other(
11608 "purpose input did not observe cancellation between chunks",
11609 )
11610 .into());
11611 }
11612 Ok(())
11613 }
11614
11615 #[test]
11616 fn publication_revalidation_rejects_source_policy_and_import_drift()
11617 -> Result<(), Box<dyn Error>> {
11618 let temp = tempfile::tempdir()?;
11619 let config_dir = temp.path().join(".projectatlas");
11620 fs::create_dir_all(&config_dir)?;
11621 let source_path = temp.path().join("lib.rs");
11622 let staged_source = "fn first() {}\n";
11623 fs::write(&source_path, staged_source)?;
11624 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11625 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11626 let db_path = config_dir.join("projectatlas.db");
11627 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
11628 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11629 let control = standalone_index_work_control();
11630 let generation_before_contention = store
11631 .index_publication()?
11632 .ok_or_else(|| io::Error::other("initial publication missing"))?
11633 .generation;
11634 let contract_fingerprint = plan.publication_contract_fingerprint();
11635 store.probe_index_publication_writer()?;
11636 let contended_batch =
11637 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11638 revalidate_staged_publication_inputs_controlled(
11639 &plan,
11640 contended_batch.nodes.expected_nodes(),
11641 None,
11642 &control,
11643 )?;
11644 let writer_blocker = rusqlite::Connection::open(&db_path)?;
11645 writer_blocker.execute_batch("BEGIN IMMEDIATE")?;
11646 let source_after_contention = "fn later() {}\n";
11647 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
11648 let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
11649 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
11650 let publisher = std::thread::spawn(move || {
11651 if ready_tx.send(()).is_err() || publish_rx.recv().is_err() {
11652 return;
11653 }
11654 let publication_control = standalone_index_work_control();
11655 let result = publish_index_batch(&mut store, contended_batch, &publication_control);
11656 drop(result_tx.send((store, result)));
11657 });
11658 ready_rx
11659 .recv_timeout(Duration::from_secs(1))
11660 .map_err(|source| {
11661 io::Error::other(format!("publisher did not become ready: {source}"))
11662 })?;
11663 fs::write(&source_path, source_after_contention)?;
11664 publish_tx
11665 .send(())
11666 .map_err(|source| io::Error::other(format!("publisher stopped early: {source}")))?;
11667 let timely_result = result_rx.recv_timeout(Duration::from_millis(500));
11668 writer_blocker.execute_batch("ROLLBACK")?;
11669 let (returned_store, contention_result) = match timely_result {
11670 Ok(result) => result,
11671 Err(source) => {
11672 publisher
11673 .join()
11674 .map_err(|_panic| io::Error::other("publication thread panicked"))?;
11675 return Err(io::Error::other(format!(
11676 "publication waited for a contending writer instead of failing fast: {source}"
11677 ))
11678 .into());
11679 }
11680 };
11681 publisher
11682 .join()
11683 .map_err(|_panic| io::Error::other("publication thread panicked"))?;
11684 store = returned_store;
11685 let Err(CliError::Db(contention)) = contention_result else {
11686 return Err(io::Error::other(
11687 "publication waited through contention and accepted stale staged source",
11688 )
11689 .into());
11690 };
11691 if !contention.is_write_unavailable() {
11692 return Err(io::Error::other(
11693 "publication contention did not return typed write-unavailable state",
11694 )
11695 .into());
11696 }
11697 require_eq(
11698 &store
11699 .index_publication()?
11700 .ok_or_else(|| io::Error::other("publication missing after contention"))?
11701 .generation,
11702 &generation_before_contention,
11703 "publication generation after contention",
11704 )?;
11705 require_eq(
11706 &store
11707 .load_file_text("lib.rs")?
11708 .ok_or_else(|| io::Error::other("indexed text missing after contention"))?
11709 .content,
11710 &staged_source.to_string(),
11711 "indexed text after contention",
11712 )?;
11713 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11714 require_eq(
11715 &store
11716 .load_file_text("lib.rs")?
11717 .ok_or_else(|| io::Error::other("indexed text missing after retry"))?
11718 .content,
11719 &source_after_contention.to_string(),
11720 "restaged text after contention retry",
11721 )?;
11722 let initial_generation = store
11723 .index_publication()?
11724 .ok_or_else(|| io::Error::other("retried publication missing"))?
11725 .generation;
11726 require_eq(
11727 &initial_generation,
11728 &generation_before_contention
11729 .checked_next()
11730 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
11731 "single generation advance after contention retry",
11732 )?;
11733 let mut competing_store = open_atlas_store_for_project(&db_path, &plan.root)?;
11734 let competing_publication = competing_store
11735 .begin_index_projection_refresh_from(&contract_fingerprint, initial_generation)?;
11736 competing_publication.set_node_summary("lib.rs", "winning projection")?;
11737 let generation_conflict_batch =
11738 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11739 competing_publication.complete()?;
11740 let winning_generation = initial_generation
11741 .checked_next()
11742 .ok_or_else(|| io::Error::other("test generation overflowed"))?;
11743 let conflict = publish_index_batch(&mut store, generation_conflict_batch, &control);
11744 if !matches!(
11745 conflict,
11746 Err(CliError::Db(
11747 projectatlas_db::DbError::PublicationBaseGenerationChanged {
11748 expected,
11749 found,
11750 }
11751 )) if expected == initial_generation && found == winning_generation
11752 ) {
11753 return Err(io::Error::other(
11754 "runtime publication did not reject a generation changed after preparation",
11755 )
11756 .into());
11757 }
11758 let winning_publication = store
11759 .index_publication()?
11760 .ok_or_else(|| io::Error::other("winning publication missing"))?;
11761 require_eq(
11762 &winning_publication.generation,
11763 &winning_generation,
11764 "winning publication generation",
11765 )?;
11766 require_eq(
11767 &winning_publication.state,
11768 &projectatlas_db::IndexPublicationState::Complete,
11769 "winning publication state",
11770 )?;
11771 require_eq(
11772 &store
11773 .load_node_by_path("lib.rs")?
11774 .and_then(|node| node.summary),
11775 &Some("winning projection".to_string()),
11776 "winning publication summary",
11777 )?;
11778 let staged_source_batch =
11779 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11780
11781 fs::write(&source_path, "fn other() {}\n")?;
11782 let source_result = revalidate_staged_publication_inputs_controlled(
11783 &plan,
11784 staged_source_batch.nodes.expected_nodes(),
11785 None,
11786 &control,
11787 );
11788 let Err(CliError::RefreshRequired(details)) = source_result else {
11789 return Err(io::Error::other("publication accepted changed source state").into());
11790 };
11791 require_eq(
11792 &details.reason,
11793 &IndexRefreshReason::SourceChanged,
11794 "publication source-change reason",
11795 )?;
11796 require_eq(
11797 &details.sample_paths,
11798 &vec!["lib.rs".to_string()],
11799 "publication source-change paths",
11800 )?;
11801
11802 fs::write(&source_path, staged_source)?;
11803 let staged_policy_batch =
11804 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
11805 let config_path = config_dir.join("config.toml");
11806 fs::write(
11807 &config_path,
11808 r#"[project]
11809root = "."
11810map_path = ".projectatlas/projectatlas.toon"
11811nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
11812
11813[scan]
11814source_extensions = [".rs"]
11815exclude_dir_names = [".git", ".projectatlas", "target"]
11816exclude_dir_suffixes = []
11817exclude_path_prefixes = []
11818non_source_path_prefixes = []
11819text_index_max_bytes = 7
11820
11821[purpose]
11822default_style = "line-comment"
11823line_comment_prefixes = ["//"]
11824
11825[purpose.styles_by_extension]
11826".rs" = "line-comment"
11827"#,
11828 )?;
11829 let policy_result = revalidate_staged_publication_inputs_controlled(
11830 &plan,
11831 staged_policy_batch.nodes.expected_nodes(),
11832 None,
11833 &control,
11834 );
11835 let Err(CliError::VerificationIncomplete(details)) = policy_result else {
11836 return Err(io::Error::other("publication accepted changed effective policy").into());
11837 };
11838 require_eq(
11839 &details.reason,
11840 &IndexVerificationReason::PublicationContractMismatch,
11841 "publication policy-change reason",
11842 )?;
11843
11844 let configured_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
11845 let request_limited_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
11846 require_eq(
11847 &configured_plan.text_options.max_bytes,
11848 &7,
11849 "configured text-index limit",
11850 )?;
11851 require_eq(
11852 &request_limited_plan.text_options.max_bytes,
11853 &1,
11854 "request-scoped text-index limit",
11855 )?;
11856 require_eq(
11857 &configured_plan.publication_contract_fingerprint(),
11858 &request_limited_plan.publication_contract_fingerprint(),
11859 "request limit excluded from publication contract",
11860 )?;
11861 let reloaded_request_plan = request_limited_plan.reload()?;
11862 require_eq(
11863 &reloaded_request_plan.text_options.max_bytes,
11864 &1,
11865 "request limit retained across operation reload",
11866 )?;
11867 require_eq(
11868 &request_limited_plan.publication_contract_fingerprint(),
11869 &reloaded_request_plan.publication_contract_fingerprint(),
11870 "reloaded operation publication contract",
11871 )?;
11872
11873 let changed_config = fs::read_to_string(&config_path)?
11874 .replace("text_index_max_bytes = 7", "text_index_max_bytes = 8");
11875 fs::write(&config_path, changed_config)?;
11876 let configured_cap_changed_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
11877 if configured_plan.publication_contract_fingerprint()
11878 == configured_cap_changed_plan.publication_contract_fingerprint()
11879 {
11880 return Err(io::Error::other(
11881 "configured text-index limit did not change the publication contract",
11882 )
11883 .into());
11884 }
11885
11886 let import_repo = temp.path().join("import-repo");
11887 let import_atlas_dir = import_repo.join(".projectatlas");
11888 fs::create_dir_all(&import_atlas_dir)?;
11889 fs::write(import_repo.join("lib.rs"), "fn imported() {}\n")?;
11890 let import_map_path = import_atlas_dir.join("projectatlas.toon");
11891 fs::write(
11892 &import_map_path,
11893 "folders[1]:\n .,Original imported repository purpose\n",
11894 )?;
11895 let external_config_path = temp.path().join("external-config.toml");
11896 fs::write(
11897 &external_config_path,
11898 r#"[project]
11899root = "import-repo"
11900map_path = ".projectatlas/projectatlas.toon"
11901nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
11902"#,
11903 )?;
11904 let import_plan =
11905 ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, Some(1))?;
11906 let mut import_store = open_atlas_store_for_project(
11907 &import_atlas_dir.join("projectatlas.db"),
11908 &import_plan.root,
11909 )?;
11910 run_scan_pipeline(&mut import_store, &import_plan, &symbol_options)?;
11911 verify_index_freshness(&import_store, &import_repo, Some(&external_config_path))?;
11912 let normal_import_plan =
11913 ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, None)?;
11914 run_symbol_build_pipeline(
11915 &mut import_store,
11916 &normal_import_plan,
11917 &symbol_options,
11918 None,
11919 )?;
11920 verify_index_publication(&import_store, &normal_import_plan)?;
11921 let publication_before = import_store
11922 .index_publication()?
11923 .ok_or_else(|| io::Error::other("initial imported publication missing"))?;
11924 let root_before = import_store
11925 .load_node_by_path(".")?
11926 .ok_or_else(|| io::Error::other("imported root node missing"))?;
11927 if root_before.purpose.purpose.as_deref() != Some("Original imported repository purpose") {
11928 return Err(io::Error::other("legacy purpose fixture was not imported").into());
11929 }
11930
11931 let import_control = standalone_index_work_control();
11932 let staged_import_batch = stage_full_index_publication(
11933 &import_store,
11934 &import_plan,
11935 &symbol_options,
11936 true,
11937 true,
11938 &import_control,
11939 )?;
11940 let staged_purpose_import = staged_import_batch
11941 .purpose_import
11942 .as_ref()
11943 .ok_or_else(|| io::Error::other("staged purpose import missing"))?;
11944 if !staged_purpose_import
11945 .records
11946 .iter()
11947 .any(|record| record.summary == "Original imported repository purpose")
11948 {
11949 return Err(io::Error::other("legacy purpose fixture was not imported").into());
11950 }
11951 fs::write(
11952 &import_map_path,
11953 "folders[1]:\n .,Changed imported repository purpose\n",
11954 )?;
11955 let import_result = revalidate_staged_publication_inputs_with_purpose_snapshot(
11956 &import_plan,
11957 staged_import_batch.nodes.expected_nodes(),
11958 Some(staged_purpose_import),
11959 &import_control,
11960 );
11961 let Err(CliError::VerificationIncomplete(details)) = import_result else {
11962 return Err(io::Error::other(
11963 "publication accepted changed legacy purpose import inputs",
11964 )
11965 .into());
11966 };
11967 require_eq(
11968 &details.reason,
11969 &IndexVerificationReason::PublicationContractMismatch,
11970 "publication import-change reason",
11971 )?;
11972 require_eq(
11973 &import_store.index_publication()?,
11974 &Some(publication_before),
11975 "publication after purpose-import rollback",
11976 )?;
11977 require_eq(
11978 &import_store.load_node_by_path(".")?,
11979 &Some(root_before),
11980 "authored purpose after purpose-import rollback",
11981 )?;
11982 Ok(())
11983 }
11984
11985 #[test]
11986 fn projection_contract_revisions_force_full_projection_refresh() -> Result<(), Box<dyn Error>> {
11987 const PRE_MODULE_CALLBACK_DIGEST: &str =
11988 "487625adf2f9ec76f98034d4ef5667e707960b6b8afd280b213021cb64a0f10f";
11989 let temp = tempfile::tempdir()?;
11990 let atlas_dir = temp.path().join(".projectatlas");
11991 fs::create_dir_all(&atlas_dir)?;
11992 fs::create_dir_all(temp.path().join("src"))?;
11993 fs::create_dir_all(temp.path().join("docs"))?;
11994 fs::write(
11995 temp.path().join("src/config.rs"),
11996 "pub fn load_timeout_millis() -> u64 { 250 }\n",
11997 )?;
11998 fs::write(
11999 temp.path().join("src/handler.rs"),
12000 "use crate::config;\npub fn health_response() { let _ = config::load_timeout_millis(); }\n",
12001 )?;
12002 fs::write(
12003 temp.path().join("src/router.rs"),
12004 "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
12005 )?;
12006 fs::write(
12007 temp.path().join("docs/guide.md"),
12008 "# Configuration\n\nSee [the timeout source](../src/config.rs).\n",
12009 )?;
12010
12011 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12012 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12013 let db_path = atlas_dir.join("projectatlas.db");
12014 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
12015 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
12016 let current_fingerprint = plan.publication_contract_fingerprint();
12017 let prior_projection_fingerprint = index_derivation_fingerprint_for_contract(
12018 &plan.scan_options,
12019 text_index_options(plan.config.as_ref(), None),
12020 #[cfg(feature = "optional-parser-supervisor")]
12021 &plan.optional_parser_selection,
12022 "2",
12023 &semantic_resolution_contract_digest(),
12024 );
12025 let prior_semantic_fingerprint = index_derivation_fingerprint_for_contract(
12026 &plan.scan_options,
12027 text_index_options(plan.config.as_ref(), None),
12028 #[cfg(feature = "optional-parser-supervisor")]
12029 &plan.optional_parser_selection,
12030 INDEX_DERIVATION_CONTRACT_VERSION,
12031 PRE_MODULE_CALLBACK_DIGEST,
12032 );
12033 if prior_projection_fingerprint == current_fingerprint
12034 || prior_semantic_fingerprint == current_fingerprint
12035 {
12036 return Err(io::Error::other(
12037 "projection or semantic contract revision did not change the derivation fingerprint",
12038 )
12039 .into());
12040 }
12041
12042 let current_generation = store
12043 .index_publication()?
12044 .ok_or_else(|| io::Error::other("current publication missing"))?
12045 .generation;
12046 store
12047 .begin_index_publication_from(&prior_projection_fingerprint, current_generation)?
12048 .complete()?;
12049 if publication_contract_matches(&store, &plan)? {
12050 return Err(io::Error::other(
12051 "prior semantic contract unexpectedly matched the current plan",
12052 )
12053 .into());
12054 }
12055 let Err(CliError::VerificationIncomplete(stale_contract)) =
12056 verify_index_publication(&store, &plan)
12057 else {
12058 return Err(io::Error::other(
12059 "stale document projection crossed the publication read boundary",
12060 )
12061 .into());
12062 };
12063 require_eq(
12064 &stale_contract.reason,
12065 &IndexVerificationReason::PublicationContractMismatch,
12066 "stale document projection read reason",
12067 )?;
12068
12069 let stale_generation = store
12070 .index_publication()?
12071 .ok_or_else(|| io::Error::other("stale publication missing"))?
12072 .generation;
12073 let control = standalone_index_work_control();
12074 refresh_index_controlled(&mut store, &plan, &symbol_options, &control)?;
12075 let refreshed = store
12076 .index_publication()?
12077 .ok_or_else(|| io::Error::other("refreshed publication missing"))?;
12078 if refreshed.generation <= stale_generation
12079 || refreshed.contract_fingerprint.as_deref() != Some(current_fingerprint.as_str())
12080 {
12081 return Err(io::Error::other(
12082 "semantic contract mismatch did not force a current full publication",
12083 )
12084 .into());
12085 }
12086 let document_relations = store.repository_graph_relations(
12087 RepositoryGraphRelationQuery::Family {
12088 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
12089 },
12090 10,
12091 )?;
12092 let document_source_is_file = document_relations.rows.iter().any(|relation| {
12093 store
12094 .repository_graph_entity(relation.source())
12095 .ok()
12096 .flatten()
12097 .is_some_and(|source| {
12098 matches!(
12099 source.selector(),
12100 EntitySelector::File { path } if path.as_str() == "docs/guide.md"
12101 )
12102 })
12103 });
12104 if !document_source_is_file {
12105 return Err(io::Error::other(
12106 "full contract refresh did not republish the document file as source",
12107 )
12108 .into());
12109 }
12110 let graphs = store.load_symbol_graphs_for_paths(&["src/router.rs".to_string()])?;
12111 if !graphs
12112 .iter()
12113 .flat_map(|graph| &graph.relations)
12114 .any(|relation| {
12115 relation.kind == projectatlas_core::symbols::RelationKind::Calls
12116 && relation.target_name == "handler::health_response"
12117 })
12118 {
12119 return Err(io::Error::other(
12120 "semantic refresh did not publish the comparison-then callback edge",
12121 )
12122 .into());
12123 }
12124 Ok(())
12125 }
12126
12127 #[cfg(feature = "optional-parser-supervisor")]
12128 #[test]
12129 fn optional_parser_selection_changes_derivation_and_preserves_prior_generation_on_failure()
12130 -> Result<(), Box<dyn Error>> {
12131 let temp = tempfile::tempdir()?;
12132 let atlas_dir = temp.path().join(".projectatlas");
12133 fs::create_dir_all(&atlas_dir)?;
12134 let source_path = temp.path().join("main.awk");
12135 fs::write(&source_path, "BEGIN { print \"atlas\" }\n")?;
12136 let inactive_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12137 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12138 let db_path = atlas_dir.join("projectatlas.db");
12139 let mut store = open_atlas_store_for_project(&db_path, &inactive_plan.root)?;
12140 run_scan_pipeline(&mut store, &inactive_plan, &symbol_options)?;
12141 if inactive_plan.scan_options.admit_optional_languages {
12142 return Err(io::Error::other(
12143 "inactive optional pack admitted catalog languages into the scan policy",
12144 )
12145 .into());
12146 }
12147 let inactive_optional = store
12148 .load_node_by_path("main.awk")?
12149 .ok_or_else(|| io::Error::other("inactive optional source node missing"))?;
12150 if inactive_optional.node.language.is_some() {
12151 return Err(io::Error::other(
12152 "inactive optional extension received a catalog language assignment",
12153 )
12154 .into());
12155 }
12156 let before = store
12157 .index_publication()?
12158 .ok_or_else(|| io::Error::other("initial publication missing"))?;
12159
12160 let selection_path = temp.path().join(repo_path_to_native(
12161 OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH,
12162 ));
12163 fs::write(
12164 &selection_path,
12165 serde_json::to_vec(&json!({
12166 "schema_version": 1,
12167 "pack_id": "broad-parser",
12168 "selected": {
12169 "projectatlas_version": OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION,
12170 "artifact": "a".repeat(64),
12171 }
12172 }))?,
12173 )?;
12174 let selected_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12175 if !selected_plan.scan_options.admit_optional_languages {
12176 return Err(io::Error::other(
12177 "selected optional pack did not enable catalog language admission",
12178 )
12179 .into());
12180 }
12181 if inactive_plan.publication_contract_fingerprint()
12182 == selected_plan.publication_contract_fingerprint()
12183 {
12184 return Err(io::Error::other(
12185 "optional parser selection did not change the derivation contract",
12186 )
12187 .into());
12188 }
12189 if publication_contract_matches(&store, &selected_plan)? {
12190 return Err(io::Error::other(
12191 "selected optional artifact unexpectedly matched the inactive publication",
12192 )
12193 .into());
12194 }
12195 if !watch_path_requires_full_scan(temp.path(), &selection_path) {
12196 return Err(io::Error::other(
12197 "optional parser selection event did not require a full refresh",
12198 )
12199 .into());
12200 }
12201
12202 let mut changes = WatchChangeSet::default();
12203 changes.paths.insert(source_path);
12204 let result =
12205 refresh_index_for_changes(&mut store, &selected_plan, &changes, &symbol_options);
12206 if !matches!(result, Err(CliError::ParserPack(_))) {
12207 return Err(io::Error::other(
12208 "missing selected artifact did not fail before publication",
12209 )
12210 .into());
12211 }
12212 require_eq(
12213 &store
12214 .index_publication()?
12215 .ok_or_else(|| io::Error::other("publication disappeared after failure"))?
12216 .generation,
12217 &before.generation,
12218 "generation after selected optional artifact failure",
12219 )?;
12220
12221 fs::remove_file(selection_path)?;
12222 let disabled_plan = selected_plan.reload()?;
12223 if disabled_plan.scan_options.admit_optional_languages {
12224 return Err(io::Error::other(
12225 "disabled optional pack retained catalog language admission",
12226 )
12227 .into());
12228 }
12229 require_eq(
12230 &disabled_plan.publication_contract_fingerprint(),
12231 &inactive_plan.publication_contract_fingerprint(),
12232 "disabled optional parser derivation contract",
12233 )?;
12234
12235 let stale_graph = SymbolGraph {
12236 path: "main.awk".to_string(),
12237 language: Some("awk".to_string()),
12238 parser: ParserKind::Fallback,
12239 symbols: Vec::new(),
12240 relations: Vec::new(),
12241 };
12242 let stale_metadata = SourceParseMetadata {
12243 path: stale_graph.path.clone(),
12244 language: stale_graph.language.clone(),
12245 parser: ParserKind::TreeSitter,
12246 symbol_count: 0,
12247 relation_count: 0,
12248 };
12249 store.replace_symbol_graph_with_metadata(&stale_graph, &stale_metadata)?;
12250 let selected_publication = store.begin_index_publication_from(
12251 &selected_plan.publication_contract_fingerprint(),
12252 before.generation,
12253 )?;
12254 selected_publication.complete()?;
12255
12256 refresh_index(&mut store, &disabled_plan, &symbol_options)?;
12257 require_eq(
12258 &store.load_source_parse_metadata("main.awk")?,
12259 &None,
12260 "disabled optional parser metadata",
12261 )?;
12262 if !publication_contract_matches(&store, &disabled_plan)? {
12263 return Err(io::Error::other(
12264 "disabled optional parser refresh did not publish its derivation contract",
12265 )
12266 .into());
12267 }
12268 Ok(())
12269 }
12270
12271 #[test]
12272 fn symbol_projection_refresh_republishes_normalized_graph_at_one_generation()
12273 -> Result<(), Box<dyn Error>> {
12274 let temp = tempfile::tempdir()?;
12275 let atlas_dir = temp.path().join(".projectatlas");
12276 fs::create_dir_all(&atlas_dir)?;
12277 fs::write(
12278 temp.path().join("lib.rs"),
12279 "pub fn caller() { target(); }\npub fn target() {}\n",
12280 )?;
12281 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12282 let mut store =
12283 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
12284 refresh_index(
12285 &mut store,
12286 &plan,
12287 &SymbolBuildOptions::new(1_024, Some(1), None),
12288 )?;
12289 let before = store
12290 .index_publication()?
12291 .ok_or_else(|| io::Error::other("initial publication missing"))?;
12292 let relation_query = RepositoryGraphRelationQuery::Family {
12293 relation: GraphRelationKind::Legacy(RelationKind::Calls),
12294 };
12295 require_eq(
12296 &store
12297 .repository_graph_relations(relation_query.clone(), 10)?
12298 .rows
12299 .len(),
12300 &1,
12301 "initial normalized call relation",
12302 )?;
12303
12304 run_symbol_build_pipeline(
12305 &mut store,
12306 &plan,
12307 &SymbolBuildOptions::new(1, Some(1), None),
12308 None,
12309 )?;
12310
12311 let after = store
12312 .index_publication()?
12313 .ok_or_else(|| io::Error::other("symbol publication missing"))?;
12314 require_eq(
12315 &after.generation,
12316 &before
12317 .generation
12318 .checked_next()
12319 .ok_or_else(|| io::Error::other("publication generation overflowed"))?,
12320 "symbol and graph publication generation",
12321 )?;
12322 require_eq(
12323 &store
12324 .repository_graph_relations(relation_query, 10)?
12325 .rows
12326 .len(),
12327 &0,
12328 "cleared symbol relation projection",
12329 )?;
12330 let project = store
12331 .project_instance_id()?
12332 .ok_or_else(|| io::Error::other("project identity missing"))?;
12333 let path = RepositoryNodePath::new(Path::new("lib.rs"))?;
12334 let entities = store.repository_graph_entities_by_path(project, &path, 10)?;
12335 require_eq(
12336 &entities
12337 .rows
12338 .iter()
12339 .all(|entity| entity.generation() == after.generation),
12340 &true,
12341 "symbol refresh normalized graph generation",
12342 )?;
12343 Ok(())
12344 }
12345
12346 #[test]
12347 fn unchanged_full_and_incremental_refreshes_do_not_advance_generation()
12348 -> Result<(), Box<dyn Error>> {
12349 let temp = tempfile::tempdir()?;
12350 let atlas_dir = temp.path().join(".projectatlas");
12351 fs::create_dir_all(&atlas_dir)?;
12352 let source_path = temp.path().join("lib.rs");
12353 fs::write(&source_path, "fn stable() {}\n")?;
12354 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12355 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12356 let mut store =
12357 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
12358 refresh_index(&mut store, &plan, &symbol_options)?;
12359 let normal_read_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12360 verify_index_publication(&store, &normal_read_plan)?;
12361 let before = store
12362 .index_publication()?
12363 .ok_or_else(|| io::Error::other("initial publication missing"))?;
12364 let project = store
12365 .project_instance_id()?
12366 .ok_or_else(|| io::Error::other("project identity missing"))?;
12367 let abandoned_full_stage = atlas_dir.join("graph-stage-full-noop");
12368 fs::create_dir(&abandoned_full_stage)?;
12369 drop(AtlasStore::create_repository_graph_staging(
12370 &abandoned_full_stage.join("projectatlas.db"),
12371 &plan.root,
12372 project,
12373 )?);
12374 let full_report = refresh_index(&mut store, &plan, &symbol_options)?;
12375 let after_full = store
12376 .index_publication()?
12377 .ok_or_else(|| io::Error::other("publication missing after full no-op refresh"))?;
12378 require_eq(
12379 &after_full.generation,
12380 &before.generation,
12381 "full no-op publication generation",
12382 )?;
12383 require_eq(
12384 &full_report.text_index.candidates,
12385 &0,
12386 "full no-op text candidates",
12387 )?;
12388 require_eq(
12389 &full_report.structural_summaries.candidates,
12390 &0,
12391 "full no-op summary candidates",
12392 )?;
12393 require_eq(
12394 &full_report.symbols.candidates,
12395 &0,
12396 "full no-op symbol candidates",
12397 )?;
12398 require_eq(
12399 &full_report.symbols.max_workers,
12400 &0,
12401 "full no-op symbol workers",
12402 )?;
12403 require_eq(
12404 &abandoned_full_stage.exists(),
12405 &false,
12406 "full no-op abandoned graph stage",
12407 )?;
12408 let abandoned_incremental_stage = atlas_dir.join("graph-stage-incremental-noop");
12409 fs::create_dir(&abandoned_incremental_stage)?;
12410 drop(AtlasStore::create_repository_graph_staging(
12411 &abandoned_incremental_stage.join("projectatlas.db"),
12412 &plan.root,
12413 project,
12414 )?);
12415 let mut changes = WatchChangeSet::default();
12416 changes.paths.insert(source_path);
12417
12418 let report = refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12419 let after = store
12420 .index_publication()?
12421 .ok_or_else(|| io::Error::other("publication missing after no-op refresh"))?;
12422
12423 require_eq(
12424 &after.generation,
12425 &before.generation,
12426 "no-op publication generation",
12427 )?;
12428 require_eq(&report.text_index.candidates, &0, "no-op text candidates")?;
12429 require_eq(
12430 &report.structural_summaries.candidates,
12431 &0,
12432 "no-op summary candidates",
12433 )?;
12434 require_eq(&report.symbols.candidates, &0, "no-op symbol candidates")?;
12435 require_eq(&report.symbols.max_workers, &0, "no-op symbol workers")?;
12436 require_eq(
12437 &abandoned_incremental_stage.exists(),
12438 &false,
12439 "incremental no-op abandoned graph stage",
12440 )?;
12441 Ok(())
12442 }
12443
12444 #[test]
12445 fn watcher_preserves_explicit_full_refresh_guidance() -> Result<(), Box<dyn Error>> {
12446 let temp = tempfile::tempdir()?;
12447 fs::write(temp.path().join("source.rs"), "pub fn source() {}\n")?;
12448 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12449 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12450 let database = temp.path().join(".projectatlas/projectatlas.db");
12451 let mut store = open_atlas_store_for_project(&database, &plan.root)?;
12452 refresh_index(&mut store, &plan, &symbol_options)?;
12453 let before = store.index_publication()?;
12454
12455 let error =
12456 run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |_| {
12457 Err(CliError::RefreshRequired(Box::new(
12458 index_policy_refresh_required(&plan.root),
12459 )))
12460 })
12461 .err()
12462 .ok_or_else(|| {
12463 io::Error::other("full-refresh guidance was hidden by polling fallback")
12464 })?;
12465 let CliError::RefreshRequired(report) = error else {
12466 return Err(io::Error::other(format!(
12467 "unexpected watcher error after full-refresh guidance: {error:?}"
12468 ))
12469 .into());
12470 };
12471 require_eq(
12472 &report.scope,
12473 &IndexRefreshScope::Full,
12474 "watcher full-refresh scope",
12475 )?;
12476 require_eq(
12477 &store.index_publication()?,
12478 &before,
12479 "watcher generation after full-refresh guidance",
12480 )?;
12481 Ok(())
12482 }
12483
12484 #[test]
12485 fn canceled_watcher_batch_preserves_last_valid_and_retries_one_generation()
12486 -> Result<(), Box<dyn Error>> {
12487 let temp = tempfile::tempdir()?;
12488 let atlas_dir = temp.path().join(".projectatlas");
12489 fs::create_dir_all(&atlas_dir)?;
12490 let reserved_purpose_path = atlas_dir.join("projectatlas-nonsource-files.toon");
12491 fs::write(&reserved_purpose_path, "nonsource_files[]:\n")?;
12492 let changed_path = temp.path().join("changed.rs");
12493 let deleted_path = temp.path().join("deleted.rs");
12494 let deleted_dir = temp.path().join("deleted");
12495 let deleted_descendant = deleted_dir.join("descendant.rs");
12496 fs::create_dir(&deleted_dir)?;
12497 fs::write(&changed_path, "pub fn before() {}\n")?;
12498 fs::write(&deleted_path, "pub fn removed() {}\n")?;
12499 fs::write(&deleted_descendant, "pub fn descendant() {}\n")?;
12500 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12501 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12502 let db_path = atlas_dir.join("projectatlas.db");
12503 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
12504 refresh_index(&mut store, &plan, &symbol_options)?;
12505 let before = store
12506 .index_publication()?
12507 .ok_or_else(|| io::Error::other("initial publication missing"))?;
12508 let before_node = store
12509 .load_node_by_path("changed.rs")?
12510 .ok_or_else(|| io::Error::other("initial changed node missing"))?;
12511 let reviewed_reserved_purpose = "Describe reviewed non-source atlas responsibilities.";
12512 store.set_purpose(
12513 ".projectatlas/projectatlas-nonsource-files.toon",
12514 reviewed_reserved_purpose,
12515 PurposeSource::Agent,
12516 )?;
12517 let old_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
12518 require_eq(
12519 &old_reader
12520 .index_publication()?
12521 .as_ref()
12522 .map(|state| state.generation),
12523 &Some(before.generation),
12524 "old reader generation before staged publication",
12525 )?;
12526 let old_text = old_reader
12527 .load_file_text("changed.rs")?
12528 .ok_or_else(|| io::Error::other("old reader text missing"))?;
12529
12530 fs::write(&changed_path, "pub fn after() {}\n")?;
12531 fs::remove_file(&deleted_path)?;
12532 fs::remove_dir_all(&deleted_dir)?;
12533 fs::write(&reserved_purpose_path, "nonsource_files[]:\n\n")?;
12534 let preparation_control = standalone_index_work_control();
12535 let staged_batch = stage_full_index_publication(
12536 &store,
12537 &plan,
12538 &symbol_options,
12539 true,
12540 false,
12541 &preparation_control,
12542 )?;
12543 revalidate_staged_publication_inputs_controlled(
12544 &plan,
12545 staged_batch.nodes.expected_nodes(),
12546 None,
12547 &preparation_control,
12548 )?;
12549 let IndexPublicationBatch {
12550 base_generation,
12551 contract_fingerprint,
12552 root,
12553 nodes,
12554 purpose_import: _,
12555 text_paths,
12556 text,
12557 content_classifications,
12558 symbols: _,
12559 graph: _,
12560 structural_summaries: _,
12561 } = staged_batch;
12562 let mut staged =
12563 store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
12564 staged.set_project_root(&root)?;
12565 let NodePublicationBatch::Full { nodes } = nodes else {
12566 return Err(io::Error::other("full staging returned an incremental batch").into());
12567 };
12568 staged.begin_scan_replacement()?;
12569 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
12570 staged.upsert_scan_node_batch(batch)?;
12571 }
12572 apply_file_content_classification_stage(
12573 &mut staged,
12574 &content_classifications,
12575 &preparation_control,
12576 )?;
12577 staged.finish_scan_replacement()?;
12578 let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
12579 late_cancel.cancel();
12580 let late_result = apply_text_index_stage(&mut staged, &text_paths, &text, &late_cancel);
12581 if !matches!(
12582 late_result,
12583 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12584 stage: IndexWorkStage::Publication,
12585 }))
12586 ) {
12587 return Err(io::Error::other("late cancellation did not stop publication").into());
12588 }
12589 drop(staged);
12590 require_eq(
12591 &store
12592 .index_publication()?
12593 .as_ref()
12594 .map(|state| state.generation),
12595 &Some(before.generation),
12596 "generation after canceled publication",
12597 )?;
12598 require_eq(
12599 &store.load_node_by_path("changed.rs")?,
12600 &Some(before_node),
12601 "last-valid node after canceled publication",
12602 )?;
12603 let mut changes = WatchChangeSet::default();
12604 changes.paths.insert(changed_path);
12605 changes.paths.insert(deleted_path);
12606 changes.paths.insert(deleted_dir);
12607 changes.paths.insert(deleted_descendant);
12608 changes.paths.insert(reserved_purpose_path);
12609 let fallback_report =
12610 run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |store| {
12611 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
12612 canceled.cancel();
12613 let Err(error) = refresh_index_for_changes_controlled(
12614 store,
12615 &plan,
12616 &changes,
12617 &symbol_options,
12618 &canceled,
12619 ) else {
12620 return Err(CliError::InvalidInput(
12621 "canceled watcher batch unexpectedly succeeded".to_string(),
12622 ));
12623 };
12624 let generation = store.index_publication()?.map(|state| state.generation);
12625 if generation != Some(before.generation) {
12626 return Err(CliError::InvalidInput(
12627 "canceled watcher batch advanced publication before fallback".to_string(),
12628 ));
12629 }
12630 Err(error)
12631 })?;
12632 require_eq(
12633 &fallback_report.mode.as_str(),
12634 &WATCH_MODE_POLLING,
12635 "canceled notify batch fallback mode",
12636 )?;
12637 if fallback_report
12638 .fallback_reason
12639 .as_deref()
12640 .is_none_or(|reason| !reason.contains("canceled"))
12641 {
12642 return Err(io::Error::other(
12643 "polling fallback did not retain the notify failure reason",
12644 )
12645 .into());
12646 }
12647
12648 let after = store
12649 .index_publication()?
12650 .ok_or_else(|| io::Error::other("incremental publication missing"))?;
12651 require_eq(
12652 &after.generation,
12653 &before
12654 .generation
12655 .checked_next()
12656 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
12657 "one incremental publication generation",
12658 )?;
12659 require_eq(
12660 &store.load_node_by_path("deleted.rs")?.is_none(),
12661 &true,
12662 "deleted path is absent",
12663 )?;
12664 require_eq(
12665 &store
12666 .load_symbols(Some("deleted.rs"), Some("removed"), 10)?
12667 .is_empty(),
12668 &true,
12669 "deleted symbols are invalidated",
12670 )?;
12671 require_eq(
12672 &store.load_node_by_path("deleted/descendant.rs")?.is_none(),
12673 &true,
12674 "deleted descendant path is absent",
12675 )?;
12676 require_eq(
12677 &store
12678 .load_symbols(Some("deleted/descendant.rs"), Some("descendant"), 10)?
12679 .is_empty(),
12680 &true,
12681 "deleted descendant symbols are invalidated",
12682 )?;
12683 require_eq(
12684 &store
12685 .load_symbols(Some("changed.rs"), Some("after"), 10)?
12686 .len(),
12687 &1,
12688 "changed symbols are published",
12689 )?;
12690 require_eq(
12691 &store
12692 .load_symbols(Some("changed.rs"), Some("before"), 10)?
12693 .is_empty(),
12694 &true,
12695 "replaced symbols are invalidated",
12696 )?;
12697 let reserved = store
12698 .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
12699 .ok_or_else(|| io::Error::other("reserved metadata node missing"))?;
12700 require_eq(
12701 &reserved.purpose.purpose.as_deref(),
12702 &Some(reviewed_reserved_purpose),
12703 "stale reviewed built-in purpose text",
12704 )?;
12705 require_eq(
12706 &reserved.purpose.status,
12707 &PurposeStatus::Approved,
12708 "reviewed built-in purpose state",
12709 )?;
12710 require_eq(
12711 &old_reader
12712 .index_publication()?
12713 .as_ref()
12714 .map(|state| state.generation),
12715 &Some(before.generation),
12716 "old reader remains on the complete prior generation",
12717 )?;
12718 require_eq(
12719 &old_reader
12720 .load_file_text("changed.rs")?
12721 .map(|text| text.content),
12722 &Some(old_text.content),
12723 "old reader remains on prior source text",
12724 )?;
12725 let new_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
12726 require_eq(
12727 &new_reader
12728 .index_publication()?
12729 .as_ref()
12730 .map(|state| state.generation),
12731 &Some(after.generation),
12732 "new reader sees the complete replacement generation",
12733 )?;
12734 require_eq(
12735 &new_reader
12736 .load_file_text("changed.rs")?
12737 .map(|text| text.content),
12738 &Some("pub fn after() {}\n".to_string()),
12739 "new reader sees replacement source text",
12740 )?;
12741 new_reader.finish_index_read_snapshot()?;
12742 old_reader.finish_index_read_snapshot()?;
12743 Ok(())
12744 }
12745
12746 #[test]
12747 fn one_sided_notify_rename_events_require_full_verification() -> Result<(), Box<dyn Error>> {
12748 let temp = tempfile::tempdir()?;
12749 let old_path = temp.path().join("old.rs");
12750 let new_path = temp.path().join("new.rs");
12751 for (mode, path) in [
12752 (notify::event::RenameMode::From, old_path),
12753 (notify::event::RenameMode::To, new_path),
12754 ] {
12755 let event =
12756 Event::new(EventKind::Modify(notify::event::ModifyKind::Name(mode))).add_path(path);
12757 let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
12758 require_eq(
12759 &changes.requires_full_scan,
12760 &true,
12761 "one-sided rename full verification",
12762 )?;
12763 }
12764 Ok(())
12765 }
12766
12767 #[test]
12768 fn notify_rescan_and_ignored_policy_events_require_full_verification()
12769 -> Result<(), Box<dyn Error>> {
12770 let temp = tempfile::tempdir()?;
12771 fs::create_dir_all(temp.path().join(".projectatlas"))?;
12772 fs::write(temp.path().join(".gitignore"), ".projectatlas/\n")?;
12773 let config = temp.path().join(".projectatlas/config.toml");
12774 fs::write(&config, "[project]\nroot = \".\"\n")?;
12775 let config_event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
12776 notify::event::DataChange::Content,
12777 )))
12778 .add_path(config.clone());
12779 let config_changes =
12780 notify_event_changes(temp.path(), &ScanOptions::default(), &config_event);
12781 require_eq(
12782 &config_changes.requires_full_scan,
12783 &true,
12784 "ignored ProjectAtlas config full verification",
12785 )?;
12786 require_eq(
12787 &config_changes.paths.contains(&config),
12788 &true,
12789 "ignored ProjectAtlas config event path",
12790 )?;
12791
12792 let rescan_event = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
12793 let rescan_changes =
12794 notify_event_changes(temp.path(), &ScanOptions::default(), &rescan_event);
12795 require_eq(
12796 &rescan_changes.requires_full_scan,
12797 &true,
12798 "backend rescan flag full verification",
12799 )?;
12800 Ok(())
12801 }
12802
12803 #[test]
12804 fn nonindexed_document_target_transitions_refresh_the_inbound_closure()
12805 -> Result<(), Box<dyn Error>> {
12806 let temp = tempfile::tempdir()?;
12807 let docs = temp.path().join("docs");
12808 fs::create_dir_all(&docs)?;
12809 fs::write(temp.path().join(".gitignore"), "!docs/target.md\n")?;
12810 fs::write(docs.join("guide.md"), "# Guide\n\n[target](target.md)\n")?;
12811 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12812 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12813 let mut store = open_atlas_store_for_project(
12814 &temp.path().join(".projectatlas/projectatlas.db"),
12815 &plan.root,
12816 )?;
12817 refresh_index(&mut store, &plan, &symbol_options)?;
12818 let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
12819 let reason = |store: &AtlasStore| -> Result<_, Box<dyn Error>> {
12820 let page = store.repository_graph_relation_rows(
12821 RepositoryGraphRelationQuery::Family {
12822 relation: documents,
12823 },
12824 8,
12825 None,
12826 )?;
12827 require_eq(&page.rows.len(), &1, "document relation count")?;
12828 Ok(page.rows[0].document_unresolved_reason)
12829 };
12830 require_eq(
12831 &reason(&store)?,
12832 &Some(DocumentTargetUnresolvedReason::Missing),
12833 "initial missing document target",
12834 )?;
12835
12836 let outside = tempfile::tempdir()?;
12837 let outside_target = outside.path().join("target.md");
12838 fs::write(&outside_target, "outside\n")?;
12839 let target = docs.join("target.md");
12840 if !create_document_target_symlink(&outside_target, &target)? {
12841 return Ok(());
12842 }
12843 let mut changes = WatchChangeSet::default();
12844 changes.document_paths.insert(target.clone());
12845 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12846 require_eq(
12847 &reason(&store)?,
12848 &Some(DocumentTargetUnresolvedReason::OutsideRoot),
12849 "missing-to-outside-symlink document target",
12850 )?;
12851
12852 fs::remove_file(&target)?;
12853 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12854 require_eq(
12855 &reason(&store)?,
12856 &Some(DocumentTargetUnresolvedReason::Missing),
12857 "outside-symlink-to-missing document target",
12858 )?;
12859 Ok(())
12860 }
12861
12862 #[cfg(unix)]
12863 fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
12864 std::os::unix::fs::symlink(target, link)?;
12865 Ok(true)
12866 }
12867
12868 #[cfg(windows)]
12869 fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
12870 match std::os::windows::fs::symlink_file(target, link) {
12871 Ok(()) => Ok(true),
12872 Err(source)
12873 if source.kind() == io::ErrorKind::PermissionDenied
12874 || source.raw_os_error() == Some(1314) =>
12875 {
12876 Ok(false)
12877 }
12878 Err(source) => Err(source),
12879 }
12880 }
12881
12882 #[test]
12883 fn directory_only_deletion_re_resolves_external_inbound_callers() -> Result<(), Box<dyn Error>>
12884 {
12885 let temp = tempfile::tempdir()?;
12886 let atlas_dir = temp.path().join(".projectatlas");
12887 let source_dir = temp.path().join("src");
12888 let removed_dir = source_dir.join("removed");
12889 fs::create_dir_all(&atlas_dir)?;
12890 fs::create_dir_all(&removed_dir)?;
12891 fs::write(
12892 source_dir.join("caller.rs"),
12893 "pub fn caller() { target(); }\n",
12894 )?;
12895 fs::write(removed_dir.join("target.rs"), "pub fn target() {}\n")?;
12896
12897 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
12898 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12899 let mut store =
12900 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
12901 refresh_index(&mut store, &plan, &symbol_options)?;
12902 let before = store
12903 .index_publication()?
12904 .ok_or_else(|| io::Error::other("initial publication missing"))?;
12905 require_caller_resolution(
12906 &store,
12907 "src/caller.rs",
12908 |resolution| matches!(resolution, RelationResolution::Resolved { .. }),
12909 "initial caller resolution",
12910 )?;
12911
12912 fs::remove_dir_all(&removed_dir)?;
12913 let mut changes = WatchChangeSet::default();
12914 changes.paths.insert(removed_dir);
12915 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
12916
12917 let after = store
12918 .index_publication()?
12919 .ok_or_else(|| io::Error::other("replacement publication missing"))?;
12920 require_eq(
12921 &after.generation,
12922 &before
12923 .generation
12924 .checked_next()
12925 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
12926 "directory deletion generation",
12927 )?;
12928 require_eq(
12929 &store.load_node_by_path("src/removed/target.rs")?.is_none(),
12930 &true,
12931 "deleted descendant node",
12932 )?;
12933 require_eq(
12934 &store
12935 .load_symbols(Some("src/removed/target.rs"), Some("target"), 10)?
12936 .is_empty(),
12937 &true,
12938 "deleted descendant symbol",
12939 )?;
12940 require_caller_resolution(
12941 &store,
12942 "src/caller.rs",
12943 |resolution| matches!(resolution, RelationResolution::Unresolved { .. }),
12944 "caller resolution after directory deletion",
12945 )?;
12946 Ok(())
12947 }
12948
12949 fn require_caller_resolution(
12950 store: &AtlasStore,
12951 caller_path: &str,
12952 expected: impl FnOnce(&RelationResolution) -> bool,
12953 label: &str,
12954 ) -> Result<(), Box<dyn Error>> {
12955 let project = store
12956 .project_instance_id()?
12957 .ok_or_else(|| io::Error::other("project identity missing"))?;
12958 let caller_path = RepositoryNodePath::new(Path::new(caller_path))?;
12959 let caller_entities =
12960 store.repository_graph_entities_by_path(project, &caller_path, 100)?;
12961 let relations = store.repository_graph_relations(
12962 RepositoryGraphRelationQuery::Family {
12963 relation: GraphRelationKind::Legacy(RelationKind::Calls),
12964 },
12965 100,
12966 )?;
12967 let mut matching = relations.rows.iter().filter(|relation| {
12968 caller_entities
12969 .rows
12970 .iter()
12971 .any(|entity| entity.key() == relation.source())
12972 });
12973 let relation = matching
12974 .next()
12975 .ok_or_else(|| io::Error::other(format!("{label}: caller relation missing")))?;
12976 if matching.next().is_some() {
12977 return Err(io::Error::other(format!("{label}: multiple caller relations")).into());
12978 }
12979 if !expected(relation.resolution()) {
12980 return Err(io::Error::other(format!(
12981 "{label}: unexpected resolution {:?}",
12982 relation.resolution()
12983 ))
12984 .into());
12985 }
12986 Ok(())
12987 }
12988
12989 #[test]
12990 fn purpose_curator_handoff_applies_one_stale_safe_batch() -> Result<(), Box<dyn Error>> {
12991 let temp = tempfile::tempdir()?;
12992 let root = temp.path().join("repository");
12993 fs::create_dir(&root)?;
12994 let database = temp.path().join("projectatlas.db");
12995 let mut store = AtlasStore::open_for_project(&database, &root)?;
12996 store.replace_scan(&[
12997 Node {
12998 path: "src/main.rs".to_string(),
12999 kind: NodeKind::File,
13000 parent_path: Some("src".to_string()),
13001 extension: Some(".rs".to_string()),
13002 language: Some("rust".to_string()),
13003 size_bytes: Some(12),
13004 mtime_ns: Some(10),
13005 content_hash: Some("hash-main".to_string()),
13006 },
13007 Node {
13008 path: "src/detail.rs".to_string(),
13009 kind: NodeKind::File,
13010 parent_path: Some("src".to_string()),
13011 extension: Some(".rs".to_string()),
13012 language: Some("rust".to_string()),
13013 size_bytes: Some(12),
13014 mtime_ns: Some(10),
13015 content_hash: Some("hash-detail".to_string()),
13016 },
13017 ])?;
13018 store.set_suggested_purpose("src/detail.rs", "Generated detail suggestion")?;
13019 let task = "runtime-purpose-curator";
13020 let page = purpose_curation_page(
13021 &store,
13022 &HealthQuery {
13023 start_index: 0,
13024 limit: 20,
13025 category: None,
13026 severity: Some(Severity::Warning),
13027 path_prefix: None,
13028 summary_only: false,
13029 scope: HealthScope::all(),
13030 },
13031 task,
13032 )?;
13033 require_eq(&page.actionable, &true, "actionable queue")?;
13034 require_eq(&page.items.len(), &2, "queue item count")?;
13035 require_eq(&page.task, &task.to_string(), "queue task")?;
13036 require_eq(
13037 &page
13038 .items
13039 .iter()
13040 .all(|item| item.classification == Some(ContentClassification::Opaque)),
13041 &true,
13042 "queue file classifications",
13043 )?;
13044 let requests = page
13045 .items
13046 .iter()
13047 .map(|item| PurposeReviewRequest {
13048 path: item.path.clone(),
13049 purpose: Some(format!("Reviewed purpose for {}", item.path)),
13050 confirm_existing: false,
13051 task: Some(page.task.clone()),
13052 work_key: Some(item.work_key.clone()),
13053 state_token: Some(item.state_token.clone()),
13054 })
13055 .collect::<Vec<_>>();
13056 let handoff = purpose_curator_handoff(page);
13057 require_eq(
13058 &handoff.execution_owner,
13059 &"agent_host",
13060 "host-owned execution",
13061 )?;
13062 require_eq(
13063 &handoff.server_started_curator,
13064 &false,
13065 "no server-started curator",
13066 )?;
13067 require_eq(
13068 &handoff.recommended_subagent_reasoning,
13069 &PURPOSE_CURATOR_RECOMMENDED_REASONING,
13070 "lowest reliable host-supported reasoning",
13071 )?;
13072 require_eq(
13073 &handoff.instructions.first().is_some_and(|instruction| {
13074 instruction.contains("lowest reliable reasoning and cost tier the host supports")
13075 }),
13076 &true,
13077 "purpose handoff omitted the reliable reasoning and cost qualification",
13078 )?;
13079 require_eq(&handoff.main_agent_fallback, &true, "main-agent fallback")?;
13080
13081 let mixed = vec![
13082 requests[0].clone(),
13083 PurposeReviewRequest {
13084 path: requests[1].path.clone(),
13085 purpose: Some("Explicit correction must stay separate".to_string()),
13086 confirm_existing: false,
13087 task: None,
13088 work_key: None,
13089 state_token: None,
13090 },
13091 ];
13092 match review_purposes(&store, &mixed, true) {
13093 Err(CliError::InvalidInput(_)) => {}
13094 Err(error) => {
13095 return Err(io::Error::other(format!(
13096 "mixed purpose batch returned the wrong error: {error}"
13097 ))
13098 .into());
13099 }
13100 Ok(_) => return Err(io::Error::other("mixed purpose batch was accepted").into()),
13101 }
13102 let mut partial = requests[1].clone();
13103 partial.state_token = None;
13104 match review_purposes(&store, &[requests[0].clone(), partial], true) {
13105 Err(CliError::InvalidInput(_)) => {}
13106 Err(error) => {
13107 return Err(io::Error::other(format!(
13108 "partial conditional batch returned the wrong error: {error}"
13109 ))
13110 .into());
13111 }
13112 Ok(_) => {
13113 return Err(io::Error::other("partial conditional batch was accepted").into());
13114 }
13115 }
13116 drop(store);
13117 store = AtlasStore::open_for_project(&database, &root)?;
13118 let unchanged =
13119 store.load_nodes_by_paths(&["src/main.rs".to_string(), "src/detail.rs".to_string()])?;
13120 require_eq(
13121 &unchanged.iter().all(|node| !node.purpose.agent_reviewed()),
13122 &true,
13123 "rejected batch left every purpose unapproved after reopen",
13124 )?;
13125
13126 let applied = review_purposes(&store, &requests, true)?;
13127 require_eq(&applied.changed, &2, "conditional batch changed count")?;
13128 require_eq(&applied.conflicts, &0, "conditional batch conflicts")?;
13129 require_eq(
13130 &applied
13131 .items
13132 .iter()
13133 .all(|item| item.classification == Some(ContentClassification::Opaque)),
13134 &true,
13135 "purpose review classifications",
13136 )?;
13137 require_eq(
13138 &applied
13139 .items
13140 .iter()
13141 .all(|item| item.action == PurposeReviewAction::Review),
13142 &true,
13143 "conditional batch actions",
13144 )?;
13145
13146 let repeated = review_purposes(&store, &requests, true)?;
13147 require_eq(&repeated.changed, &0, "accepted repeat changed count")?;
13148 require_eq(&repeated.conflicts, &2, "accepted repeat conflicts")?;
13149 require_eq(
13150 &repeated
13151 .items
13152 .iter()
13153 .all(|item| item.action == PurposeReviewAction::Accepted),
13154 &true,
13155 "accepted repeat actions",
13156 )?;
13157 let empty = purpose_curation_page(
13158 &store,
13159 &HealthQuery {
13160 start_index: 0,
13161 limit: 20,
13162 category: None,
13163 severity: Some(Severity::Warning),
13164 path_prefix: None,
13165 summary_only: false,
13166 scope: HealthScope::all(),
13167 },
13168 task,
13169 )?;
13170 require_eq(&empty.actionable, &false, "accepted queue is quiet")?;
13171
13172 let correction = review_purposes(
13173 &store,
13174 &[PurposeReviewRequest {
13175 path: "src/main.rs".to_string(),
13176 purpose: Some("Explicit corrected purpose".to_string()),
13177 confirm_existing: false,
13178 task: None,
13179 work_key: None,
13180 state_token: None,
13181 }],
13182 true,
13183 )?;
13184 require_eq(
13185 &correction.items[0].action,
13186 &PurposeReviewAction::Review,
13187 "explicit correction action",
13188 )?;
13189 let corrected = store
13190 .load_node_by_path("src/main.rs")?
13191 .ok_or_else(|| io::Error::other("corrected runtime path disappeared"))?;
13192 require_eq(
13193 &corrected.purpose.purpose.as_deref(),
13194 &Some("Explicit corrected purpose"),
13195 "explicit correction value",
13196 )?;
13197 Ok(())
13198 }
13199
13200 #[test]
13201 fn purpose_review_admission_bounds_input_and_prevents_partial_apply()
13202 -> Result<(), Box<dyn Error>> {
13203 let temp = tempfile::tempdir()?;
13204 let root = temp.path().join("repository");
13205 fs::create_dir(&root)?;
13206 let database = temp.path().join("projectatlas.db");
13207 let mut store = AtlasStore::open_for_project(&database, &root)?;
13208 store.replace_scan(&[
13209 Node {
13210 path: "src/first.rs".to_string(),
13211 kind: NodeKind::File,
13212 parent_path: Some("src".to_string()),
13213 extension: Some(".rs".to_string()),
13214 language: Some("rust".to_string()),
13215 size_bytes: Some(12),
13216 mtime_ns: Some(10),
13217 content_hash: Some("hash-first".to_string()),
13218 },
13219 Node {
13220 path: "src/second.rs".to_string(),
13221 kind: NodeKind::File,
13222 parent_path: Some("src".to_string()),
13223 extension: Some(".rs".to_string()),
13224 language: Some("rust".to_string()),
13225 size_bytes: Some(12),
13226 mtime_ns: Some(10),
13227 content_hash: Some("hash-second".to_string()),
13228 },
13229 ])?;
13230 store.set_suggested_purpose("src/first.rs", "Generated first purpose")?;
13231 store.set_purpose(
13232 "src/second.rs",
13233 &"x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1),
13234 PurposeSource::Imported,
13235 )?;
13236
13237 let valid_first = PurposeReviewRequest {
13238 path: "src/first.rs".to_string(),
13239 purpose: Some("Reviewed café λ purpose".to_string()),
13240 confirm_existing: false,
13241 task: None,
13242 work_key: None,
13243 state_token: None,
13244 };
13245 let oversized_report = PurposeReviewRequest {
13246 path: "src/second.rs".to_string(),
13247 purpose: None,
13248 confirm_existing: true,
13249 task: None,
13250 work_key: None,
13251 state_token: None,
13252 };
13253 let Err(error) = review_purposes(&store, &[valid_first.clone(), oversized_report], true)
13254 else {
13255 return Err(io::Error::other(
13256 "oversized retained report field was accepted before apply",
13257 )
13258 .into());
13259 };
13260 require_eq(
13261 &error
13262 .to_string()
13263 .contains("purpose review report field purpose"),
13264 &true,
13265 "oversized report field error",
13266 )?;
13267 let unchanged = store
13268 .load_node_by_path("src/first.rs")?
13269 .ok_or_else(|| io::Error::other("first review fixture disappeared"))?;
13270 require_eq(
13271 &unchanged.purpose.agent_reviewed(),
13272 &false,
13273 "report admission failure prevented partial apply",
13274 )?;
13275
13276 let oversized_field = PurposeReviewRequest {
13277 purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1)),
13278 ..valid_first.clone()
13279 };
13280 let Err(error) = review_purposes(&store, &[oversized_field], true) else {
13281 return Err(io::Error::other("oversized request field passed admission").into());
13282 };
13283 require_eq(
13284 &error.to_string().contains("field purpose"),
13285 &true,
13286 "oversized input field error",
13287 )?;
13288
13289 let too_many = vec![valid_first.clone(); MAX_PURPOSE_CURATION_BATCH_ROWS + 1];
13290 let Err(error) = review_purposes(&store, &too_many, true) else {
13291 return Err(io::Error::other("oversized request count passed admission").into());
13292 };
13293 require_eq(
13294 &error.to_string().contains("maximum is 200"),
13295 &true,
13296 "oversized item count error",
13297 )?;
13298
13299 let aggregate = (0..9)
13300 .map(|index| PurposeReviewRequest {
13301 path: format!("src/{index}.rs"),
13302 purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES)),
13303 confirm_existing: false,
13304 task: None,
13305 work_key: None,
13306 state_token: None,
13307 })
13308 .collect::<Vec<_>>();
13309 let Err(error) = review_purposes(&store, &aggregate, false) else {
13310 return Err(io::Error::other("oversized aggregate request passed admission").into());
13311 };
13312 require_eq(
13313 &error.to_string().contains("aggregate string bytes"),
13314 &true,
13315 "oversized aggregate input error",
13316 )?;
13317
13318 let preview = review_purposes(&store, &[valid_first], false)?;
13319 require_eq(
13320 &preview.items[0].purpose,
13321 &"Reviewed café λ purpose".to_string(),
13322 "UTF-8 purpose compatibility",
13323 )?;
13324 require_eq(
13325 &render_purpose_review_report(&preview).contains("Reviewed café λ purpose"),
13326 &true,
13327 "UTF-8 TOON compatibility",
13328 )?;
13329 Ok(())
13330 }
13331
13332 #[test]
13333 fn indexed_navigation_read_rejects_stale_or_oversized_source_before_allocation()
13334 -> Result<(), Box<dyn Error>> {
13335 let temp = tempfile::tempdir()?;
13336 let root = temp.path().join("repository");
13337 let source_dir = root.join("src");
13338 fs::create_dir_all(&source_dir)?;
13339 let source = source_dir.join("large.rs");
13340 fs::File::create(&source)?.set_len(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)?;
13341 let database = temp.path().join("projectatlas.db");
13342 let mut store = AtlasStore::open_for_project(&database, &root)?;
13343 store.replace_scan(&[Node {
13344 path: "src/large.rs".to_string(),
13345 kind: NodeKind::File,
13346 parent_path: Some("src".to_string()),
13347 extension: Some(".rs".to_string()),
13348 language: Some("rust".to_string()),
13349 size_bytes: Some(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1),
13350 mtime_ns: Some(10),
13351 content_hash: Some("unused-oversized-hash".to_string()),
13352 }])?;
13353
13354 let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
13355 return Err(
13356 io::Error::other("oversized indexed source was allocated and accepted").into(),
13357 );
13358 };
13359 let CliError::VerificationIncomplete(details) = error else {
13360 return Err(io::Error::other("oversized source returned the wrong error type").into());
13361 };
13362 require_eq(
13363 &details.reason,
13364 &IndexVerificationReason::SourceTooLarge,
13365 "oversized source reason",
13366 )?;
13367
13368 fs::File::create(&source)?.set_len(1)?;
13369 let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
13370 return Err(io::Error::other("changed source size did not require refresh").into());
13371 };
13372 let CliError::RefreshRequired(details) = error else {
13373 return Err(
13374 io::Error::other("changed source size returned the wrong error type").into(),
13375 );
13376 };
13377 require_eq(
13378 &details.reason,
13379 &IndexRefreshReason::SourceChanged,
13380 "changed source size reason",
13381 )?;
13382 Ok(())
13383 }
13384
13385 #[cfg(windows)]
13386 #[test]
13387 fn extended_windows_watch_roots_keep_deleted_paths_in_scope() -> Result<(), Box<dyn Error>> {
13388 let root = Path::new(r"\\?\C:\repo");
13389 let deleted = Path::new(r"C:\repo\src\deleted.rs");
13390 require_eq(
13391 &normalized_deleted_path(root, deleted)?,
13392 &Some("src/deleted.rs".to_string()),
13393 "extended root deleted path",
13394 )?;
13395 Ok(())
13396 }
13397
13398 #[cfg(windows)]
13399 #[test]
13400 fn notify_events_normalize_unicode_paths_against_extended_windows_roots()
13401 -> Result<(), Box<dyn Error>> {
13402 let temp = tempfile::tempdir()?;
13403 let source_dir = temp.path().join("src");
13404 fs::create_dir(&source_dir)?;
13405 let deleted = source_dir.join("Über.rs");
13406 fs::write(&deleted, "pub fn before() {}\n")?;
13407 fs::remove_file(&deleted)?;
13408
13409 let root_text = temp
13410 .path()
13411 .to_str()
13412 .ok_or_else(|| io::Error::other("temporary path is not UTF-8"))?;
13413 let extended_root = if root_text.starts_with(r"\\?\") {
13414 temp.path().to_path_buf()
13415 } else {
13416 PathBuf::from(format!(r"\\?\{root_text}"))
13417 };
13418 let event = Event::new(EventKind::Remove(notify::event::RemoveKind::File))
13419 .add_path(deleted.clone());
13420 let changes = notify_event_changes(&extended_root, &ScanOptions::default(), &event);
13421
13422 require_eq(
13423 &changes.paths.contains(&deleted),
13424 &true,
13425 "native Unicode watcher path",
13426 )?;
13427 require_eq(
13428 &changes.requires_full_scan,
13429 &false,
13430 "source removal full-scan policy",
13431 )?;
13432 require_eq(
13433 &normalized_deleted_path(&extended_root, &deleted)?,
13434 &Some("src/Über.rs".to_string()),
13435 "normalized Unicode deleted path",
13436 )?;
13437 Ok(())
13438 }
13439
13440 #[cfg(unix)]
13441 #[test]
13442 fn notify_events_preserve_native_backslash_paths_on_unix() -> Result<(), Box<dyn Error>> {
13443 let temp = tempfile::tempdir()?;
13444 let source = temp.path().join(r"src\generated.rs");
13445 fs::write(&source, "pub fn generated() {}\n")?;
13446 let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
13447 notify::event::DataChange::Content,
13448 )))
13449 .add_path(source.clone());
13450
13451 let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
13452
13453 require_eq(
13454 &changes.paths,
13455 &HashSet::from([source]),
13456 "native Unix watcher paths",
13457 )?;
13458 Ok(())
13459 }
13460
13461 fn runtime_pdf_fixture() -> Vec<u8> {
13462 let objects = [
13463 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
13464 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
13465 b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n".as_slice(),
13466 b"4 0 obj\n<< /Length 42 >>\nstream\nBT /F1 12 Tf 72 720 Td (Runtime PDF) Tj ET\nendstream\nendobj\n".as_slice(),
13467 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
13468 ];
13469 let mut pdf = b"%PDF-1.4\n".to_vec();
13470 let mut offsets = Vec::new();
13471 for object in objects {
13472 offsets.push(pdf.len());
13473 pdf.extend_from_slice(object);
13474 }
13475 let xref = pdf.len();
13476 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
13477 pdf.extend_from_slice(b"0000000000 65535 f \n");
13478 for offset in offsets {
13479 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
13480 }
13481 pdf.extend_from_slice(
13482 format!(
13483 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
13484 objects.len() + 1
13485 )
13486 .as_bytes(),
13487 );
13488 pdf
13489 }
13490
13491 #[test]
13492 fn document_input_does_not_consume_retained_text_capacity() -> Result<(), Box<dyn Error>> {
13493 let temp = tempfile::tempdir()?;
13494 let pdf = runtime_pdf_fixture();
13495 let mut nodes = Vec::new();
13496 for (path, language, bytes) in [
13497 ("seed.txt", "text", b"seed".as_slice()),
13498 ("guide.pdf", "pdf", pdf.as_slice()),
13499 ] {
13500 fs::write(temp.path().join(path), bytes)?;
13501 nodes.push(Node {
13502 path: path.to_owned(),
13503 kind: NodeKind::File,
13504 parent_path: None,
13505 extension: None,
13506 language: Some(language.to_owned()),
13507 size_bytes: Some(bytes.len() as u64),
13508 mtime_ns: Some(1),
13509 content_hash: Some(blake3::hash(bytes).to_hex().to_string()),
13510 });
13511 }
13512 let control = standalone_index_work_control();
13513 for known_size in [true, false] {
13514 nodes[1].size_bytes = known_size.then_some(pdf.len() as u64);
13515 let rows = indexed_file_texts_for_nodes_with_limit(
13516 temp.path(),
13517 &nodes,
13518 TextIndexOptions::new(64),
13519 15,
13520 &control,
13521 )?;
13522 if rows.len() != 2
13523 || rows[1].text.as_ref().map(|text| text.content.as_str()) != Some("Runtime PDF")
13524 {
13525 return Err(
13526 io::Error::other("document input consumed retained-text capacity").into(),
13527 );
13528 }
13529 if !matches!(
13530 indexed_file_texts_for_nodes_with_limit(
13531 temp.path(),
13532 &nodes,
13533 TextIndexOptions::new(64),
13534 14,
13535 &control,
13536 ),
13537 Err(CliError::IndexWork(
13538 IndexWorkFailure::ResourceLimitExceeded {
13539 stage: IndexWorkStage::TextIndex,
13540 resource: IndexWorkResource::TextBytes,
13541 ..
13542 }
13543 ))
13544 ) {
13545 return Err(io::Error::other(
13546 "extracted document text exceeded retained-text capacity",
13547 )
13548 .into());
13549 }
13550 }
13551 Ok(())
13552 }
13553
13554 #[test]
13555 fn document_text_staging_preserves_typed_resource_refusal() -> Result<(), Box<dyn Error>> {
13556 let temp = tempfile::tempdir()?;
13557 let maximum = projectatlas_symbols::MAX_DOCUMENT_COMPRESSED_BYTES;
13558 let mut bytes = vec![b' '; maximum + 1];
13559 bytes[..5].copy_from_slice(b"%PDF-");
13560 fs::write(temp.path().join("oversized.pdf"), &bytes)?;
13561 let node = Node {
13562 path: "oversized.pdf".to_owned(),
13563 kind: NodeKind::File,
13564 parent_path: None,
13565 extension: Some(".pdf".to_owned()),
13566 language: Some("pdf".to_owned()),
13567 size_bytes: Some(bytes.len() as u64),
13568 mtime_ns: Some(1),
13569 content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
13570 };
13571 let result = indexed_file_texts_for_nodes(
13572 temp.path(),
13573 &[node],
13574 TextIndexOptions::new(MAX_SYMBOL_FILE_BYTES),
13575 );
13576 if matches!(
13577 result,
13578 Err(CliError::IndexWork(
13579 IndexWorkFailure::ResourceLimitExceeded {
13580 stage: IndexWorkStage::TextIndex,
13581 resource: IndexWorkResource::SourceBytes,
13582 ..
13583 }
13584 ))
13585 ) {
13586 Ok(())
13587 } else {
13588 Err(io::Error::other(format!(
13589 "document resource refusal lost its type: {result:?}"
13590 ))
13591 .into())
13592 }
13593 }
13594
13595 #[test]
13596 fn document_bytes_reach_text_and_symbol_publication_paths() -> Result<(), Box<dyn Error>> {
13597 let temp = tempfile::tempdir()?;
13598 let path = temp.path().join("guide.pdf");
13599 let bytes = runtime_pdf_fixture();
13600 fs::write(&path, &bytes)?;
13601 let node = Node {
13602 path: "guide.pdf".to_owned(),
13603 kind: NodeKind::File,
13604 parent_path: None,
13605 extension: Some(".pdf".to_owned()),
13606 language: Some("pdf".to_owned()),
13607 size_bytes: Some(bytes.len() as u64),
13608 mtime_ns: Some(1),
13609 content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
13610 };
13611 let rows = indexed_file_texts_for_nodes(
13612 temp.path(),
13613 std::slice::from_ref(&node),
13614 TextIndexOptions::new(projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64),
13615 )?;
13616 require_eq(&rows.len(), &1, "document text row count")?;
13617 require_eq(
13618 &rows[0].text.as_ref().map(|text| text.content.as_str()),
13619 &Some("Runtime PDF"),
13620 "document text content",
13621 )?;
13622 let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
13623 &SymbolParseJob {
13624 path: node.path,
13625 native_path: path,
13626 expected_content_hash: node.content_hash.unwrap_or_default(),
13627 language: node.language,
13628 fallback_summary: None,
13629 purpose_needs_suggestion: false,
13630 },
13631 &SymbolBuildOptions::new(
13632 projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
13633 Some(1),
13634 None,
13635 ),
13636 Instant::now(),
13637 ) else {
13638 return Err(io::Error::other("document symbol job did not parse").into());
13639 };
13640 require_eq(&parsed.graph.symbols.len(), &1, "document symbol count")?;
13641 require_eq(
13642 &parsed.graph.symbols[0]
13643 .signature
13644 .contains("pdf:page=1;text-span="),
13645 &true,
13646 "document locator persisted in graph symbol",
13647 )?;
13648 Ok(())
13649 }
13650
13651 #[test]
13652 fn empty_document_refresh_discards_previous_summary() -> Result<(), Box<dyn Error>> {
13653 let bytes = runtime_pdf_fixture();
13654 let mut job = SymbolParseJob {
13655 path: "guide.pdf".to_owned(),
13656 native_path: PathBuf::from("guide.pdf"),
13657 expected_content_hash: String::new(),
13658 language: Some("pdf".to_owned()),
13659 fallback_summary: None,
13660 purpose_needs_suggestion: true,
13661 };
13662 let options = SymbolBuildOptions::new(
13663 projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
13664 Some(1),
13665 None,
13666 );
13667 let control = standalone_index_work_control();
13668 let SymbolParseOutcome::Parsed(previous) =
13669 parse_document_symbol_job(&job, &bytes, &options, &control)
13670 else {
13671 return Err(io::Error::other("document did not parse").into());
13672 };
13673 require_eq(&previous.graph.symbols.len(), &1, "previous document block")?;
13674 require_eq(
13675 &previous.summary.contains("Runtime PDF"),
13676 &true,
13677 "literal document summary",
13678 )?;
13679 require_eq(
13680 &previous
13681 .purpose_suggestion
13682 .as_deref()
13683 .unwrap_or_default()
13684 .contains("Runtime PDF"),
13685 &true,
13686 "literal document purpose suggestion",
13687 )?;
13688 job.fallback_summary = Some(previous.summary);
13689 let empty = String::from_utf8(bytes)?.replace("Runtime PDF", " ");
13690 let SymbolParseOutcome::Parsed(current) =
13691 parse_document_symbol_job(&job, empty.as_bytes(), &options, &control)
13692 else {
13693 return Err(io::Error::other("empty document did not parse").into());
13694 };
13695 require_eq(¤t.graph.symbols.len(), &0, "empty document blocks")?;
13696 require_eq(
13697 ¤t.summary.contains("Runtime PDF"),
13698 &false,
13699 "removed document text",
13700 )?;
13701 require_eq(
13702 ¤t.summary.contains("document-block-1"),
13703 &false,
13704 "summary must discard removed document blocks",
13705 )?;
13706 require_eq(
13707 ¤t
13708 .purpose_suggestion
13709 .as_deref()
13710 .unwrap_or_default()
13711 .contains("document-block-1"),
13712 &false,
13713 "suggested purpose must discard removed document blocks",
13714 )?;
13715 Ok(())
13716 }
13717
13718 #[cfg(unix)]
13719 #[test]
13720 fn notify_events_preserve_non_utf8_worktree_root_identity() -> Result<(), Box<dyn Error>> {
13721 use std::ffi::OsString;
13722 use std::os::unix::ffi::OsStringExt;
13723
13724 let temp = tempfile::tempdir()?;
13725 let root = temp
13726 .path()
13727 .join(OsString::from_vec(b"worktree-root-\xff".to_vec()));
13728 let source = root.join("src").join("generated.rs");
13729 fs::create_dir_all(
13730 source
13731 .parent()
13732 .ok_or_else(|| io::Error::other("watch source has no parent"))?,
13733 )?;
13734 fs::write(&source, "pub fn generated() {}\n")?;
13735 let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
13736 notify::event::DataChange::Content,
13737 )))
13738 .add_path(source.clone());
13739
13740 let changes = notify_event_changes(&root, &ScanOptions::default(), &event);
13741
13742 require_eq(
13743 &changes.paths,
13744 &HashSet::from([source.clone()]),
13745 "non-UTF-8 worktree-root watcher path",
13746 )?;
13747 require_eq(
13748 &normalized_deleted_path(&root, &source)?,
13749 &Some("src/generated.rs".to_string()),
13750 "non-UTF-8 worktree-root relative watcher path",
13751 )?;
13752 Ok(())
13753 }
13754
13755 #[cfg(unix)]
13757 fn require_condition(condition: bool, label: &str) -> Result<(), Box<dyn Error>> {
13758 if condition {
13759 Ok(())
13760 } else {
13761 Err(io::Error::other(label).into())
13762 }
13763 }
13764
13765 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
13767 where
13768 T: Debug + PartialEq,
13769 {
13770 if actual == expected {
13771 Ok(())
13772 } else {
13773 Err(io::Error::other(format!(
13774 "{label} mismatch: expected {expected:?}, got {actual:?}"
13775 ))
13776 .into())
13777 }
13778 }
13779}