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, SourceReadAdmission, 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, detect_language_for_path,
42 language_capability, language_registry_digest, language_registry_report,
43 normalized_language_extension,
44};
45#[cfg(all(test, feature = "optional-parser-supervisor"))]
46use projectatlas_core::optional_parser_pack::OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION;
47use projectatlas_core::outline::estimate_tokens;
48use projectatlas_core::relation_capabilities::{
49 RelationFamilyInventoryReport, relation_family_inventory_report,
50};
51use projectatlas_core::symbols::{
52 ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
53};
54use projectatlas_core::telemetry::{
55 TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
56 TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
57 UsageInstanceId, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
58};
59use projectatlas_core::toon::{encode_agent_payload, render_ranked_node_rows, render_symbol_rows};
60use projectatlas_core::{
61 CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure,
62 IndexWorkResource, IndexWorkStage, Node, NodeKind, Overview, PurposeSource, PurposeStatus,
63 normalize_native_path_display, normalize_native_path_display_str, normalize_repo_path,
64 purpose_review_signal, repo_path_to_native, validated_repo_file_key, validated_repo_node_key,
65};
66use projectatlas_db::{
67 AtlasStore, ClassifiedSymbol, DatabasePublicationContractState, DatabasePublicationReport,
68 DatabaseSchemaCompatibility, DatabaseSettingsReport, DbResult, FileContentClassification,
69 HealthFindingsPage, HealthQuery, HealthScope, IndexPublication, IndexPublicationGuard,
70 IndexPublicationState, IndexedFileText, MAX_FILE_CONTENT_CLASSIFICATION_PATHS,
71 MAX_PURPOSE_CURATION_BATCH_ROWS, PurposeConditionalApplyRequest, PurposeConditionalApplyState,
72 TelemetryRetentionState, WorktreeRegistration, WorktreeUsageSnapshot, database_settings_report,
73 preflight_project_binding_read_only, read_legacy_project_root_candidate_read_only,
74 read_project_root_identity_read_only, validate_database_location,
75};
76use projectatlas_fs::worktree::{
77 GitManagerSourceSelection, GitRepositorySelection, GitWorktreeState, RepositoryStructure,
78 git_administrative_identity, git_worktree_lifecycle_matches,
79};
80use projectatlas_fs::{
81 FsError, RootScanPolicy, ScanLimits, ScanOptions, gitignore_excludes_path,
82 scan_path_with_policy_controlled, scan_repo, scan_repo_controlled,
83 scan_repo_controlled_with_work,
84};
85use projectatlas_service::{
86 ClassifiedRankedNode, CoverageDiscoveryReport, FederatedInputWork, FederatedStore,
87 FilePathMatcher, MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, NextStepReport,
88 TokenReport, TokenReportRequest,
89 build_next_report_with_selection as build_next_report_with_selection_service,
90 load_agent_efficiency_comparison,
91 load_classified_ranked_file_nodes_with_reasons as load_classified_ranked_file_nodes_with_reasons_service,
92 load_ranked_file_nodes_with_reasons, load_ranked_folder_nodes_with_reasons,
93 validate_federated_root_count,
94};
95use projectatlas_symbols::{
96 DocumentExtractionError, DocumentLimit, MAX_DOCUMENT_COMPRESSED_BYTES, MarkdownFacts,
97 SourceSemanticFacts, document_format_for_path, extract_document_symbol_facts_controlled,
98 extract_document_text_controlled, extract_markdown_facts_controlled,
99 extract_symbol_graph_with_semantics_controlled, semantic_resolution_contract_digest,
100};
101use rayon::ThreadPoolBuilder;
102use rayon::prelude::*;
103use serde::{Deserialize, Serialize};
104use serde_json::{Value, json};
105use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
106use std::fmt;
107use std::fmt::Write as _;
108use std::fs;
109use std::io::{self, Read};
110use std::path::{Path, PathBuf};
111use std::sync::Arc;
112use std::sync::atomic::{AtomicBool, Ordering};
113use std::sync::mpsc::{self, RecvTimeoutError, TrySendError};
114use std::thread;
115use std::time::{Duration, Instant};
116
117pub(crate) const MAX_SYMBOL_FILE_BYTES: u64 = 2_000_000;
119pub(crate) const DEFAULT_HEALTH_LIMIT: usize = 50;
121pub(crate) const MAX_HEALTH_LIMIT: usize = 200;
123pub(crate) const MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES: u64 = 2 * 1_024 * 1_024;
125pub(crate) const PURPOSE_CURATOR_RECOMMENDED_REASONING: &str = "lowest_reliable_host_supported";
127const DEFAULT_INDEX_WORK_TIMEOUT: Duration = Duration::from_mins(30);
129const MAX_STAGED_TEXT_BYTES: u64 = 512 * 1024 * 1024;
131const MAX_PUBLICATION_STAGING_BYTES: u64 = 2 * 1024 * 1024 * 1024;
133const PUBLICATION_NODE_BATCH_SIZE: usize = 1_024;
135const PUBLICATION_PATH_BATCH_SIZE: usize = 128;
137const PUBLICATION_TEXT_BATCH_SIZE: usize = 32;
139#[cfg(not(feature = "optional-parser-supervisor"))]
141const SYMBOL_PARSE_BATCH_SIZE: usize = 64;
142const MAX_SYMBOL_PARSE_JOBS: usize = 100_000;
144const MAX_INCREMENTAL_CHANGED_PATHS: usize = 100_000;
146const MAX_INCREMENTAL_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
148const WATCH_EVENT_QUEUE_CAPACITY: usize = 1_024;
150pub(crate) const INDEX_WORKER_SAFE_CEILING: usize = 32;
152const CONTROLLED_SOURCE_READ_BUFFER_BYTES: usize = 8_192;
154const MAX_PURPOSE_IMPORT_BYTES: u64 = 512 * 1_024 * 1_024;
156const MAX_PURPOSE_INPUT_FILE_BYTES: u64 = 16 * 1_024 * 1_024;
158const MAX_PURPOSE_REVIEW_PATH_BYTES: usize = 4 * 1_024;
160const MAX_PURPOSE_REVIEW_FIELD_BYTES: usize = 64 * 1_024;
162const PURPOSE_REVIEW_REPORT_ERROR_FIELD: &str = "error";
164const MAX_PURPOSE_REVIEW_INPUT_BYTES: usize = 512 * 1_024;
166const MAX_PURPOSE_REVIEW_REPORT_BYTES: usize = 4 * 1_024 * 1_024;
168const MAX_PURPOSE_HEADER_BYTES: u64 = 256 * 1_024;
170const MAX_PURPOSE_IMPORT_RECORDS: u64 = 1_000_000;
172
173const BUILTIN_PROJECTATLAS_PURPOSES: &[(&str, &str)] = &[
175 (
176 ".projectatlas",
177 "Store project-local ProjectAtlas metadata, configuration, and runtime state.",
178 ),
179 (
180 ".projectatlas/config.toml",
181 "Configure project-local ProjectAtlas scan, lint, purpose, and output policy.",
182 ),
183 (
184 ".projectatlas/projectatlas-nonsource-files.toon",
185 "Declare project-local non-source file purposes for ProjectAtlas map compatibility.",
186 ),
187 (
188 ".projectatlas/projectatlas-purpose-review.json",
189 "Replay agent-reviewed ProjectAtlas purpose records into the local SQLite index.",
190 ),
191];
192const CORE_INDEX_POLICY_PATHS: &[&str] = &[".projectatlas/config.toml", "projectatlas.toml"];
194
195pub(crate) struct ScanRuntimePlan {
197 pub(crate) root: PathBuf,
199 pub(crate) config: Option<atlas_map::AtlasMapConfig>,
201 selected_config_path: Option<PathBuf>,
203 config_path_override: Option<PathBuf>,
205 pub(crate) scan_options: ScanOptions,
207 pub(crate) text_options: TextIndexOptions,
209 text_index_max_bytes_override: Option<u64>,
211 #[cfg(feature = "optional-parser-supervisor")]
213 optional_parser_selection: OptionalParserPackProjectSelection,
214}
215
216struct PurposeImportSnapshot {
218 records: Vec<atlas_map::ImportedPurposeRecord>,
220 fingerprint: String,
222}
223
224#[derive(Clone, Copy)]
226struct PurposeImportLimits {
227 total_bytes: u64,
229 complete_file_bytes: u64,
231 header_bytes: u64,
233 records: u64,
235}
236
237impl Default for PurposeImportLimits {
238 fn default() -> Self {
239 Self {
240 total_bytes: MAX_PURPOSE_IMPORT_BYTES,
241 complete_file_bytes: MAX_PURPOSE_INPUT_FILE_BYTES,
242 header_bytes: MAX_PURPOSE_HEADER_BYTES,
243 records: MAX_PURPOSE_IMPORT_RECORDS,
244 }
245 }
246}
247
248struct PurposeInputReader<'a> {
250 control: &'a IndexWorkControl,
252 limits: PurposeImportLimits,
254 complete_paths: BTreeSet<PathBuf>,
256 purpose_filename: String,
258 complete_digests: BTreeMap<PathBuf, String>,
260}
261
262const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8;
264const NORMAL_READ_REFRESH_MAX_PATHS: usize = 64;
266const NORMAL_READ_REFRESH_MAX_BYTES: u64 = 8 * 1024 * 1024;
268const MAX_INDEXED_NAVIGATION_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
270const INDEX_DERIVATION_CONTRACT_VERSION: &str = "24";
272
273#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
275#[serde(rename_all = "snake_case")]
276pub(crate) enum IndexReadStatus {
277 InitRequired,
279 WorktreeRequired,
281 RefreshRequired,
283 VerificationIncomplete,
285 ProjectMismatch,
287}
288
289#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
291#[serde(rename_all = "snake_case")]
292pub(crate) enum IndexRefreshReason {
293 SourceChanged,
295 PathsChanged,
297 PolicyDrift,
299 DependencyClosureLimit,
301}
302
303#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
305#[serde(rename_all = "snake_case")]
306pub(crate) enum IndexRefreshScope {
307 Incremental,
309 Full,
311}
312
313struct IndexFreshnessDelta {
315 report: IndexRefreshRequired,
317 paths: HashSet<PathBuf>,
319 paths_complete: bool,
321 requires_full_scan: bool,
323}
324
325#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
327pub(crate) struct SourceVerificationWork {
328 pub(crate) filesystem_entries: u64,
330 pub(crate) filesystem_bytes: u64,
332 pub(crate) sqlite_read_statements: u64,
334 pub(crate) decoded_nodes: u64,
336}
337
338pub(crate) struct IndexFreshnessAssessment {
340 delta: Option<IndexFreshnessDelta>,
342 work: SourceVerificationWork,
344 source: CapturedSourceState,
346}
347
348struct CapturedSourceState {
350 root: PathBuf,
352 project_instance_id: ProjectInstanceId,
354 contract_fingerprint: String,
356 generation: IndexGeneration,
358 nodes: Vec<Node>,
360}
361
362pub(crate) struct ExactFreshIndexRead {
364 pub(crate) store: AtlasStore,
366 pub(crate) work: SourceVerificationWork,
368}
369
370#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
372#[serde(rename_all = "snake_case")]
373pub(crate) enum IndexVerificationReason {
374 PolicyUnavailable,
376 SourceInspectionFailed,
378 SourceTooLarge,
380 ProjectIdentityUnavailable,
382 PublicationIncomplete,
384 PublicationContractMismatch,
386}
387
388#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
390pub(crate) struct IndexInitRequired {
391 pub(crate) project_root: Option<String>,
393 pub(crate) database: Option<String>,
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub(crate) worktree: Option<String>,
398 pub(crate) status: IndexReadStatus,
400}
401
402#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
404pub(crate) struct ProjectWorktreeRequired {
405 pub(crate) project_root: Option<String>,
407 pub(crate) status: IndexReadStatus,
409}
410
411#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
413pub(crate) struct IndexRefreshRequired {
414 pub(crate) project_root: Option<String>,
416 #[serde(skip_serializing_if = "Option::is_none")]
418 pub(crate) worktree: Option<String>,
419 pub(crate) status: IndexReadStatus,
421 pub(crate) reason: IndexRefreshReason,
423 pub(crate) scope: IndexRefreshScope,
425 pub(crate) changed: usize,
427 pub(crate) added: usize,
429 pub(crate) removed: usize,
431 pub(crate) modified: usize,
433 pub(crate) sample_paths: Vec<String>,
435}
436
437#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
439pub(crate) struct IndexVerificationIncomplete {
440 pub(crate) project_root: Option<String>,
442 #[serde(skip_serializing_if = "Option::is_none")]
444 pub(crate) worktree: Option<String>,
445 pub(crate) status: IndexReadStatus,
447 pub(crate) reason: IndexVerificationReason,
449 pub(crate) scope: IndexRefreshScope,
451 pub(crate) message: String,
453}
454
455#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
457pub(crate) struct IndexProjectMismatch {
458 pub(crate) status: IndexReadStatus,
460 #[serde(skip_serializing_if = "Option::is_none")]
462 pub(crate) worktree: Option<String>,
463 pub(crate) selected_project_root: Option<String>,
468 pub(crate) indexed_project_root: Option<String>,
473 #[serde(skip)]
475 diagnostic: Option<String>,
476}
477
478impl IndexProjectMismatch {
479 pub(crate) fn from_native_roots(
481 selected: &CanonicalProjectRoot,
482 indexed: &CanonicalProjectRoot,
483 ) -> Self {
484 Self {
485 status: IndexReadStatus::ProjectMismatch,
486 worktree: None,
487 selected_project_root: selected.display_string().ok(),
488 indexed_project_root: indexed.display_string().ok(),
489 diagnostic: None,
490 }
491 }
492}
493
494impl fmt::Display for IndexRefreshRequired {
495 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
496 let recovery = self.worktree.as_ref().map_or_else(
497 || "run `projectatlas watch --once` or `atlas_watch_once` before retrying".to_string(),
498 |alias| format!("call `atlas_watch_once` with `worktree: {alias}` before retrying"),
499 );
500 if self.reason == IndexRefreshReason::PolicyDrift {
501 return write!(
502 formatter,
503 "refresh_required: derived index policy differs from the current project configuration; {recovery}"
504 );
505 }
506 if self.reason == IndexRefreshReason::DependencyClosureLimit {
507 let recovery = self.worktree.as_ref().map_or_else(
508 || "run a complete `projectatlas scan` or `atlas_scan` before retrying".to_string(),
509 |alias| format!("call `atlas_scan` with `worktree: {alias}` before retrying"),
510 );
511 return write!(
512 formatter,
513 "refresh_required: the dependency-aware incremental closure exceeded its safe limit; {recovery}"
514 );
515 }
516 write!(
517 formatter,
518 "refresh_required: {} indexed path(s) differ from current local source; {recovery}",
519 self.changed,
520 )
521 }
522}
523
524impl fmt::Display for IndexInitRequired {
525 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
526 if let Some(alias) = self.worktree.as_ref() {
527 return write!(
528 formatter,
529 "init_required: ProjectAtlas index '{}' is missing for registered worktree '{}'; call `atlas_init` with `worktree: {alias}`",
530 self.database
531 .as_deref()
532 .unwrap_or("<native display unavailable>"),
533 alias,
534 );
535 }
536 write!(
537 formatter,
538 "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`",
539 self.database
540 .as_deref()
541 .unwrap_or("<native display unavailable>"),
542 self.project_root
543 .as_deref()
544 .unwrap_or("<native display unavailable>")
545 )
546 }
547}
548
549impl fmt::Display for ProjectWorktreeRequired {
550 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
551 write!(
552 formatter,
553 "worktree_required: '{}' is a bare/common Git directory without checked-out source; select a checked-out worktree and initialize that exact root",
554 self.project_root
555 .as_deref()
556 .unwrap_or("<native display unavailable>")
557 )
558 }
559}
560
561impl fmt::Display for IndexVerificationIncomplete {
562 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
563 write!(
564 formatter,
565 "verification_incomplete: current local source could not be verified safely: {}",
566 self.message
567 )
568 }
569}
570
571impl fmt::Display for IndexProjectMismatch {
572 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
573 if let Some(diagnostic) = self.diagnostic.as_deref() {
574 return formatter.write_str(diagnostic);
575 }
576 write!(
577 formatter,
578 "project_mismatch: selected project root '{}' does not match index root '{}'",
579 self.selected_project_root
580 .as_deref()
581 .unwrap_or("<native display unavailable>"),
582 self.indexed_project_root
583 .as_deref()
584 .unwrap_or("<native display unavailable>"),
585 )
586 }
587}
588
589#[cfg(test)]
591fn verify_index_freshness(
592 store: &AtlasStore,
593 root: &Path,
594 config_path: Option<&Path>,
595) -> Result<(), CliError> {
596 let plan = ScanRuntimePlan::for_path(config_path, root, None).map_err(|source| {
597 verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &source)
598 })?;
599 match detect_index_freshness(store, &plan)? {
600 Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
601 None => Ok(()),
602 }
603}
604
605pub(crate) fn open_fresh_atlas_store_for_project(
607 db_path: &Path,
608 root: &Path,
609 config_path: Option<&Path>,
610) -> Result<AtlasStore, CliError> {
611 let control = standalone_index_work_control();
612 open_fresh_atlas_store_for_project_controlled(db_path, root, config_path, &control)
613}
614
615pub(crate) fn open_fresh_atlas_store_for_project_controlled(
617 db_path: &Path,
618 root: &Path,
619 config_path: Option<&Path>,
620 control: &IndexWorkControl,
621) -> Result<AtlasStore, CliError> {
622 Ok(
623 open_exact_fresh_atlas_store_for_project_controlled(db_path, root, config_path, control)?
624 .store,
625 )
626}
627
628pub(crate) fn open_exact_fresh_atlas_store_for_project_controlled(
630 db_path: &Path,
631 root: &Path,
632 config_path: Option<&Path>,
633 control: &IndexWorkControl,
634) -> Result<ExactFreshIndexRead, CliError> {
635 open_exact_fresh_atlas_store_for_project_with_repair(
636 db_path,
637 root,
638 config_path,
639 control,
640 true,
641 ScanLimits::default(),
642 )
643}
644
645pub(crate) fn verify_saved_source_matches_index_controlled(
647 db_path: &Path,
648 root: &Path,
649 config_path: Option<&Path>,
650 control: &IndexWorkControl,
651) -> Result<(), CliError> {
652 let exact =
653 open_exact_saved_source_matches_index_controlled(db_path, root, config_path, control)?;
654 exact.store.finish_index_read_snapshot()?;
655 Ok(())
656}
657
658pub(crate) fn open_exact_saved_source_matches_index_controlled(
660 db_path: &Path,
661 root: &Path,
662 config_path: Option<&Path>,
663 control: &IndexWorkControl,
664) -> Result<ExactFreshIndexRead, CliError> {
665 open_exact_fresh_atlas_store_for_project_with_repair(
666 db_path,
667 root,
668 config_path,
669 control,
670 false,
671 ScanLimits::default(),
672 )
673}
674
675fn open_exact_fresh_atlas_store_for_project_with_repair(
677 db_path: &Path,
678 root: &Path,
679 config_path: Option<&Path>,
680 control: &IndexWorkControl,
681 repair_safe_delta: bool,
682 scan_limits: ScanLimits,
683) -> Result<ExactFreshIndexRead, CliError> {
684 let bounded_control = bounded_index_work_control(control);
685 let control = &bounded_control;
686 let (store, plan, assessment) =
687 open_exact_index_assessment(db_path, root, config_path, scan_limits, control)?;
688 let mut work = assessment.work;
689 let Some(delta) = assessment.delta else {
690 return Ok(ExactFreshIndexRead { store, work });
691 };
692 if !repair_safe_delta {
693 return Err(CliError::RefreshRequired(Box::new(delta.report)));
694 }
695 if delta.report.scope != IndexRefreshScope::Incremental {
696 return Err(CliError::RefreshRequired(Box::new(delta.report)));
697 }
698
699 let refresh_required = delta.report.clone();
700 drop(store);
701 let repair = (|| {
702 let mut writer = open_atlas_store_for_project(db_path, &plan.root)?;
703 let changes = WatchChangeSet {
704 requires_full_scan: false,
705 document_paths: delta.paths.clone(),
706 paths: delta.paths,
707 };
708 refresh_index_for_changes_from_source_controlled(
709 &mut writer,
710 &plan,
711 &changes,
712 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
713 Some(assessment.source),
714 control,
715 )
716 })();
717 if let Err(error) = repair {
718 if automatic_refresh_write_is_unavailable(&error) {
719 return Err(CliError::RefreshRequired(Box::new(refresh_required)));
720 }
721 return Err(error);
722 }
723
724 let store = open_atlas_store_read_only_for_project(db_path, &plan.root)?;
725 verify_index_project_root(&store, &plan.root)?;
726 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
727 verify_index_publication(&store, &plan)?;
728 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
729 Ok(ExactFreshIndexRead { store, work })
730}
731
732fn open_exact_index_assessment(
734 db_path: &Path,
735 root: &Path,
736 config_path: Option<&Path>,
737 scan_limits: ScanLimits,
738 control: &IndexWorkControl,
739) -> Result<(AtlasStore, ScanRuntimePlan, IndexFreshnessAssessment), CliError> {
740 let store = open_atlas_store_read_only_for_project(db_path, root)?;
741 let plan = ScanRuntimePlan::for_path_controlled(config_path, root, None, control)
742 .map_err(|source| publication_input_error(root, source))?;
743 let assessment = match detect_index_freshness_controlled(&store, &plan, scan_limits, control) {
744 Ok(assessment) => assessment,
745 Err(CliError::VerificationIncomplete(report))
746 if report.reason == IndexVerificationReason::PublicationContractMismatch =>
747 {
748 return Err(CliError::RefreshRequired(Box::new(
749 index_policy_refresh_required(&plan.root),
750 )));
751 }
752 Err(error) => return Err(error),
753 };
754 Ok((store, plan, assessment))
755}
756
757pub(crate) fn open_federated_atlas_stores_for_project(
759 selected_db: &Path,
760 selected_root: &Path,
761 selected_config: Option<&Path>,
762 roots: &[PathBuf],
763 worktrees: Option<&[String]>,
764 control: &IndexWorkControl,
765) -> Result<Vec<FederatedStore>, CliError> {
766 validate_federated_root_count(roots.len()).map_err(CliError::Service)?;
767 if worktrees.is_some_and(|worktrees| worktrees.len() != roots.len()) {
768 return Err(CliError::Service(
769 projectatlas_service::ServiceError::InvalidInput(
770 "federated worktree labels must match the ordered root count".to_string(),
771 ),
772 ));
773 }
774 let selected_root = fs::canonicalize(selected_root).map_err(|source| CliError::Io {
775 path: selected_root.to_path_buf(),
776 source,
777 })?;
778 let mut canonical_roots = Vec::with_capacity(roots.len());
779 for root in roots {
780 let root = fs::canonicalize(root).map_err(|source| CliError::Io {
781 path: root.clone(),
782 source,
783 })?;
784 if canonical_roots.contains(&root) {
785 return Err(CliError::Service(
786 projectatlas_service::ServiceError::InvalidInput(
787 "federated roots must be unique".to_string(),
788 ),
789 ));
790 }
791 canonical_roots.push(root);
792 }
793 if canonical_roots.first() != Some(&selected_root) {
794 return Err(CliError::Service(
795 projectatlas_service::ServiceError::InvalidInput(
796 "the first federated root must be the selected project root".to_string(),
797 ),
798 ));
799 }
800
801 let databases = canonical_roots
802 .iter()
803 .enumerate()
804 .map(|(order, root)| {
805 if order == 0 {
806 selected_db.to_path_buf()
807 } else {
808 root.join(".projectatlas").join("projectatlas.db")
809 }
810 })
811 .collect::<Vec<_>>();
812 let mut database_bytes = 0_u64;
813 for database in &databases {
814 let metadata = fs::metadata(database).map_err(|source| CliError::Io {
815 path: database.clone(),
816 source,
817 })?;
818 if !metadata.is_file() {
819 return Err(CliError::Service(
820 projectatlas_service::ServiceError::InvalidInput(
821 "federated database path is not a regular file".to_string(),
822 ),
823 ));
824 }
825 database_bytes = database_bytes.checked_add(metadata.len()).ok_or_else(|| {
826 CliError::Service(projectatlas_service::ServiceError::InvalidInput(
827 "participating database byte count overflowed".to_string(),
828 ))
829 })?;
830 if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
831 return Err(CliError::Service(
832 projectatlas_service::ServiceError::InvalidInput(format!(
833 "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
834 )),
835 ));
836 }
837 }
838
839 let default_scan_limits = ScanLimits::default();
840 let mut remaining_input_bytes = MAX_FEDERATED_INPUT_BYTES;
841 let mut stores: Vec<FederatedStore> = Vec::with_capacity(canonical_roots.len());
842 for (order, (root, database)) in canonical_roots.into_iter().zip(databases).enumerate() {
843 let started = Instant::now();
844 let config = (order == 0).then_some(selected_config).flatten();
845 let exact = match open_exact_fresh_atlas_store_for_project_with_repair(
846 &database,
847 &root,
848 config,
849 control,
850 false,
851 ScanLimits::new(
852 default_scan_limits.max_entries(),
853 remaining_input_bytes,
854 default_scan_limits.max_workers(),
855 ),
856 ) {
857 Ok(exact) => exact,
858 Err(error) => {
859 for store in stores {
860 drop(store.finish());
861 }
862 let error =
863 if let Some(worktree) = worktrees.and_then(|worktrees| worktrees.get(order)) {
864 federated_worktree_error(error, worktree)
865 } else {
866 error
867 };
868 return Err(error);
869 }
870 };
871 remaining_input_bytes = remaining_input_bytes.saturating_sub(exact.work.filesystem_bytes);
872 let input_work = FederatedInputWork {
873 filesystem_entries: exact.work.filesystem_entries,
874 filesystem_bytes: exact.work.filesystem_bytes,
875 sqlite_read_statements: exact.work.sqlite_read_statements,
876 decoded_nodes: exact.work.decoded_nodes,
877 elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
878 };
879 let worktree = worktrees.map(|worktrees| worktrees[order].clone());
880 match FederatedStore::new_with_worktree(exact.store, database, root, input_work, worktree) {
881 Ok(store) => stores.push(store),
882 Err(error) => {
883 for store in stores {
884 drop(store.finish());
885 }
886 return Err(CliError::Service(error));
887 }
888 }
889 }
890 Ok(stores)
891}
892
893fn with_synchronized_registered_worktree_usage<T>(
895 control_db: &Path,
896 control_root: &Path,
897 expected_control_project: Option<ProjectInstanceId>,
898 read: impl FnOnce(&AtlasStore) -> DbResult<T>,
899) -> Result<T, CliError> {
900 synchronize_registered_worktree_usage_with_catalog_validation(
901 control_db,
902 control_root,
903 expected_control_project,
904 |_, _| Ok(()),
905 || Ok(()),
906 read,
907 )
908}
909
910pub(crate) fn load_synchronized_repository_token_report(
912 control_db: &Path,
913 control_root: &Path,
914 expected_control_project: Option<ProjectInstanceId>,
915 request: TokenReportRequest<'_>,
916) -> Result<TokenReport, CliError> {
917 let (benchmark_results, trend_window) = match request {
918 TokenReportRequest::RepositoryOverview { benchmark_results } => (benchmark_results, None),
919 TokenReportRequest::RepositoryTrends { window } => (None, Some(window)),
920 TokenReportRequest::Overview { .. } | TokenReportRequest::Trends { .. } => {
921 return Err(CliError::InvalidInput(
922 "synchronized token reports require repository scope".to_string(),
923 ));
924 }
925 };
926 let mut report = with_synchronized_registered_worktree_usage(
927 control_db,
928 control_root,
929 expected_control_project,
930 |control| match trend_window {
931 None => Ok(TokenReport::Overview(Box::new(
932 control.repository_token_overview()?,
933 ))),
934 Some(window) => Ok(TokenReport::Trends(
935 control.repository_token_trends(window)?,
936 )),
937 },
938 )?;
939 if let (Some(benchmark_results), TokenReport::Overview(overview)) =
940 (benchmark_results, &mut report)
941 {
942 let control = open_atlas_store_read_only_for_project(control_db, control_root)?;
943 overview.set_agent_efficiency(load_agent_efficiency_comparison(
944 &control,
945 Some(benchmark_results),
946 )?);
947 }
948 Ok(report)
949}
950
951#[cfg(test)]
953pub(crate) fn synchronize_registered_worktree_usage(
954 control_db: &Path,
955 control_root: &Path,
956 expected_control_project: Option<ProjectInstanceId>,
957) -> Result<(), CliError> {
958 with_synchronized_registered_worktree_usage(
959 control_db,
960 control_root,
961 expected_control_project,
962 |_| Ok(()),
963 )
964}
965
966fn synchronize_registered_worktree_usage_with_catalog_validation<T>(
968 control_db: &Path,
969 control_root: &Path,
970 expected_control_project: Option<ProjectInstanceId>,
971 mut before_snapshot_revalidation: impl FnMut(&WorktreeRegistration, &Path) -> Result<(), CliError>,
972 before_catalog_validation: impl FnOnce() -> Result<(), CliError>,
973 read: impl FnOnce(&AtlasStore) -> DbResult<T>,
974) -> Result<T, CliError> {
975 let control_reader = open_atlas_store_read_only_for_project(control_db, control_root)?;
976 let control_project =
977 require_synchronization_control_identity(&control_reader, expected_control_project)?;
978 let mut registrations = control_reader.worktree_registrations(false)?;
979 drop(control_reader);
980 if registrations.is_empty() {
981 before_catalog_validation()?;
982 let control = open_atlas_store_for_project(control_db, control_root)?;
983 require_synchronization_control_identity(&control, Some(control_project))?;
984 return match control.with_matching_active_worktree_catalog(®istrations, || read(&control))?
985 {
986 Some(value) => Ok(value),
987 None => Err(CliError::InvalidInput(
988 "registered worktree catalog changed during aggregate synchronization; retry the token report"
989 .to_string(),
990 )),
991 };
992 }
993 let repository = match projectatlas_fs::worktree::discover_repository_structure(control_root)? {
994 RepositoryStructure::Git(repository) => repository,
995 RepositoryStructure::NonGit { selected_root } => {
996 return Err(CliError::InvalidInput(format!(
997 "registered worktree synchronization requires Git control evidence at '{}'",
998 normalize_native_path_display(selected_root)
999 )));
1000 }
1001 RepositoryStructure::InvalidGit {
1002 selected_root,
1003 issue,
1004 } => {
1005 return Err(CliError::InvalidInput(format!(
1006 "registered worktree synchronization found invalid Git evidence for '{}': {:?} at '{}'",
1007 normalize_native_path_display(selected_root),
1008 issue.kind,
1009 normalize_native_path_display(issue.path)
1010 )));
1011 }
1012 };
1013 let control = open_atlas_store_for_project(control_db, control_root)?;
1014 require_synchronization_control_identity(&control, Some(control_project))?;
1015 let common = CanonicalProjectRoot::from_path(&repository.common_directory)
1016 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
1017 let active_roots = repository
1018 .worktrees
1019 .iter()
1020 .filter_map(|entry| match &entry.state {
1021 GitWorktreeState::Active { root, .. } => {
1022 CanonicalProjectRoot::from_path(&entry.administrative_directory)
1023 .ok()
1024 .map(|administrative_directory| (administrative_directory, root.as_path()))
1025 }
1026 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => None,
1027 })
1028 .collect::<HashMap<_, _>>();
1029 for registration in &mut registrations {
1030 let synchronization_incomplete = || {
1031 CliError::InvalidInput(format!(
1032 "registered worktree '{}': its bound local atlas is unavailable, so aggregate token totals cannot be synchronized",
1033 registration.alias
1034 ))
1035 };
1036 if registration.git_common_directory_identity != common {
1037 if registration.project_instance_id.is_some() {
1038 return Err(synchronization_incomplete());
1039 }
1040 continue;
1041 }
1042 let root = active_roots
1043 .get(®istration.git_administrative_directory_identity)
1044 .copied()
1045 .filter(|_| {
1046 git_administrative_identity(
1047 registration.git_administrative_directory_identity.as_path(),
1048 )
1049 .is_ok_and(|identity| identity == registration.git_administrative_identity)
1050 });
1051 let Some(root) = root else {
1052 if registration.project_instance_id.is_some() {
1053 return Err(synchronization_incomplete());
1054 }
1055 continue;
1056 };
1057 let synchronized_project = control.with_active_worktree_registration(
1058 registration.registration_id,
1059 ®istration.alias,
1060 |guard| {
1061 if let Err(error) =
1062 require_registered_worktree_lifecycle(guard.registration(), root)
1063 {
1064 return Ok(Err(error));
1065 }
1066 let database = root.join(".projectatlas").join("projectatlas.db");
1067 match fs::symlink_metadata(&database) {
1068 Ok(_) => {}
1069 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1070 return Ok(if guard.registration().project_instance_id.is_some() {
1071 Err(synchronization_incomplete())
1072 } else {
1073 Ok(None)
1074 });
1075 }
1076 Err(error) => return Ok(Err(error.into())),
1077 }
1078 let target = match open_atlas_store_read_only_for_project(&database, root) {
1079 Ok(target) => target,
1080 Err(error) => return Ok(Err(error)),
1081 };
1082 let snapshot = match target.export_worktree_usage_snapshot() {
1083 Ok(snapshot) => snapshot,
1084 Err(error) => return Ok(Err(error.into())),
1085 };
1086 drop(target);
1087 if let Err(error) = before_snapshot_revalidation(guard.registration(), root) {
1088 return Ok(Err(error));
1089 }
1090 if let Err(error) =
1091 require_registered_worktree_lifecycle(guard.registration(), root)
1092 {
1093 return Ok(Err(error));
1094 }
1095 if let Err(error) =
1096 require_current_worktree_usage_snapshot(&database, root, &snapshot)
1097 {
1098 return Ok(Err(error));
1099 }
1100 let project = snapshot.project_instance_id();
1101 if guard.registration().project_instance_id.is_none() {
1102 guard.bind_project_with_usage_snapshot(root, project, &snapshot)?;
1103 } else {
1104 guard.synchronize_usage_snapshot(&snapshot)?;
1105 }
1106 Ok(Ok(Some(project)))
1107 },
1108 )??;
1109 if let Some(project) = synchronized_project {
1110 registration.project_instance_id = Some(project);
1111 }
1112 }
1113 before_catalog_validation()?;
1114 match control.with_matching_active_worktree_catalog(®istrations, || read(&control))? {
1115 Some(value) => Ok(value),
1116 None => Err(CliError::InvalidInput(
1117 "registered worktree catalog changed during aggregate synchronization; retry the token report"
1118 .to_string(),
1119 )),
1120 }
1121}
1122
1123pub(crate) fn require_current_worktree_usage_snapshot(
1125 database: &Path,
1126 root: &Path,
1127 expected: &WorktreeUsageSnapshot,
1128) -> Result<(), CliError> {
1129 let current = open_atlas_store_read_only_for_project(database, root)?;
1130 if current.export_worktree_usage_snapshot()? != *expected {
1131 return Err(CliError::InvalidInput(
1132 "worktree atlas changed after its usage snapshot was captured; retry the operation"
1133 .to_string(),
1134 ));
1135 }
1136 Ok(())
1137}
1138
1139pub(crate) fn require_registered_worktree_lifecycle(
1141 registration: &WorktreeRegistration,
1142 expected_root: &Path,
1143) -> Result<(), CliError> {
1144 let lifecycle_changed = || {
1145 CliError::InvalidInput(format!(
1146 "registered worktree '{}': its Git administrative lifecycle changed before aggregate token synchronization",
1147 registration.alias
1148 ))
1149 };
1150 if !git_worktree_lifecycle_matches(
1151 expected_root,
1152 registration.git_common_directory_identity.as_path(),
1153 registration.git_administrative_directory_identity.as_path(),
1154 ®istration.git_administrative_identity,
1155 )? {
1156 return Err(lifecycle_changed());
1157 }
1158 Ok(())
1159}
1160
1161fn require_synchronization_control_identity(
1163 control: &AtlasStore,
1164 expected: Option<ProjectInstanceId>,
1165) -> Result<ProjectInstanceId, CliError> {
1166 let observed = control.project_instance_id()?.ok_or_else(|| {
1167 CliError::InvalidInput(
1168 "control atlas has no project identity for registered worktree synchronization"
1169 .to_string(),
1170 )
1171 })?;
1172 if expected.is_some_and(|expected| expected != observed) {
1173 return Err(CliError::InvalidInput(
1174 "control atlas identity changed before registered worktree synchronization".to_string(),
1175 ));
1176 }
1177 Ok(observed)
1178}
1179
1180pub(crate) fn federated_worktree_error(mut error: CliError, worktree: &str) -> CliError {
1182 match &mut error {
1183 CliError::InitRequired(report) => report.worktree = Some(worktree.to_string()),
1184 CliError::RefreshRequired(report) => report.worktree = Some(worktree.to_string()),
1185 CliError::VerificationIncomplete(report) => report.worktree = Some(worktree.to_string()),
1186 CliError::ProjectMismatch(report) => report.worktree = Some(worktree.to_string()),
1187 _ => {
1188 return CliError::InvalidInput(format!(
1189 "federated worktree {worktree:?} failed: {error}"
1190 ));
1191 }
1192 }
1193 error
1194}
1195
1196fn automatic_refresh_write_is_unavailable(error: &CliError) -> bool {
1198 matches!(error, CliError::Db(source) if source.is_write_unavailable())
1199}
1200
1201fn index_policy_refresh_required(root: &Path) -> IndexRefreshRequired {
1203 IndexRefreshRequired {
1204 project_root: lossless_project_root_display(root),
1205 worktree: None,
1206 status: IndexReadStatus::RefreshRequired,
1207 reason: IndexRefreshReason::PolicyDrift,
1208 scope: IndexRefreshScope::Full,
1209 changed: 0,
1210 added: 0,
1211 removed: 0,
1212 modified: 0,
1213 sample_paths: Vec::new(),
1214 }
1215}
1216
1217#[cfg(test)]
1219fn detect_index_freshness(
1220 store: &AtlasStore,
1221 plan: &ScanRuntimePlan,
1222) -> Result<Option<IndexFreshnessDelta>, CliError> {
1223 let control = standalone_index_work_control();
1224 Ok(detect_index_freshness_controlled(store, plan, ScanLimits::default(), &control)?.delta)
1225}
1226
1227fn detect_index_freshness_controlled(
1229 store: &AtlasStore,
1230 plan: &ScanRuntimePlan,
1231 scan_limits: ScanLimits,
1232 control: &IndexWorkControl,
1233) -> Result<IndexFreshnessAssessment, CliError> {
1234 let mut work = SourceVerificationWork::default();
1235 verify_index_project_root(store, &plan.root)?;
1236 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1237 let generation = verify_index_publication(store, plan)?;
1238 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1239 assess_source_against_publication(store, plan, scan_limits, control, generation, work)
1240}
1241
1242fn assess_source_against_publication(
1244 store: &AtlasStore,
1245 plan: &ScanRuntimePlan,
1246 scan_limits: ScanLimits,
1247 control: &IndexWorkControl,
1248 generation: IndexGeneration,
1249 mut work: SourceVerificationWork,
1250) -> Result<IndexFreshnessAssessment, CliError> {
1251 let scan = scan_repo_controlled_with_work(&plan.root, &plan.scan_options, scan_limits, control)
1252 .map_err(|source| source_inspection_error(&plan.root, source))?;
1253 work.filesystem_entries = scan.work.entries;
1254 work.filesystem_bytes = scan.work.source_bytes;
1255 let current_nodes = scan.nodes;
1256 let mut current = current_nodes.iter().peekable();
1257 let mut delta = SourceDelta::default();
1258 store.visit_source_nodes_controlled(control, |indexed| {
1259 delta.compare_next(&mut current, &indexed);
1260 work.decoded_nodes = work.decoded_nodes.saturating_add(1);
1261 Ok(())
1262 })?;
1263 for node in current {
1264 delta.record(Some(node), None);
1265 }
1266 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1267 let current_generation = publication_base_generation(store)?;
1268 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1269 if current_generation != generation {
1270 return Err(projectatlas_db::DbError::PublicationBaseGenerationChanged {
1271 expected: generation,
1272 found: current_generation,
1273 }
1274 .into());
1275 }
1276 let project_instance_id = store.captured_project_binding()?.project_instance_id;
1277 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
1278 Ok(IndexFreshnessAssessment {
1279 delta: delta.finish(&plan.root),
1280 work,
1281 source: CapturedSourceState {
1282 root: plan.root.clone(),
1283 project_instance_id,
1284 contract_fingerprint: plan.publication_contract_fingerprint(),
1285 generation,
1286 nodes: current_nodes,
1287 },
1288 })
1289}
1290
1291fn verify_source_nodes_match(
1293 root: &Path,
1294 current_nodes: &[Node],
1295 indexed_nodes: &[Node],
1296) -> Result<(), CliError> {
1297 match source_node_delta(root, current_nodes, indexed_nodes) {
1298 Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
1299 None => Ok(()),
1300 }
1301}
1302
1303fn source_node_delta(
1305 root: &Path,
1306 current_nodes: &[Node],
1307 indexed_nodes: &[Node],
1308) -> Option<IndexFreshnessDelta> {
1309 let mut current = current_nodes.iter().collect::<Vec<_>>();
1310 let mut indexed = indexed_nodes.iter().collect::<Vec<_>>();
1311 current.sort_unstable_by(|left, right| left.path.cmp(&right.path));
1312 indexed.sort_unstable_by(|left, right| left.path.cmp(&right.path));
1313 let mut current = current.into_iter().peekable();
1314 let mut delta = SourceDelta::default();
1315 for node in indexed {
1316 delta.compare_next(&mut current, node);
1317 }
1318 for node in current {
1319 delta.record(Some(node), None);
1320 }
1321 delta.finish(root)
1322}
1323
1324#[derive(Default)]
1326struct SourceDelta {
1327 added: usize,
1329 removed: usize,
1331 modified: usize,
1333 changed_bytes: u64,
1335 folder_changed: bool,
1337 paths: Vec<String>,
1339}
1340
1341impl SourceDelta {
1342 fn compare_next<'a>(
1344 &mut self,
1345 current: &mut std::iter::Peekable<impl Iterator<Item = &'a Node>>,
1346 indexed: &Node,
1347 ) {
1348 while current.peek().is_some_and(|node| node.path < indexed.path) {
1349 self.record(current.next(), None);
1350 }
1351 if current.peek().is_some_and(|node| node.path == indexed.path) {
1352 self.record(current.next(), Some(indexed));
1353 } else {
1354 self.record(None, Some(indexed));
1355 }
1356 }
1357
1358 fn record(&mut self, current: Option<&Node>, indexed: Option<&Node>) {
1360 let path = match (current, indexed) {
1361 (Some(current), Some(indexed)) => {
1362 if same_indexed_source(current, indexed) {
1363 return;
1364 }
1365 self.modified += 1;
1366 ¤t.path
1367 }
1368 (Some(current), None) => {
1369 self.added += 1;
1370 ¤t.path
1371 }
1372 (None, Some(indexed)) => {
1373 self.removed += 1;
1374 &indexed.path
1375 }
1376 (None, None) => return,
1377 };
1378 self.changed_bytes = self
1379 .changed_bytes
1380 .saturating_add(current.and_then(|node| node.size_bytes).unwrap_or_default());
1381 self.folder_changed |= current.is_some_and(|node| node.kind == NodeKind::Folder)
1382 || indexed.is_some_and(|node| node.kind == NodeKind::Folder);
1383 if self.paths.len() < MAX_INCREMENTAL_CHANGED_PATHS {
1384 self.paths.push(path.clone());
1385 }
1386 }
1387
1388 fn finish(self, root: &Path) -> Option<IndexFreshnessDelta> {
1390 let changed = self
1391 .added
1392 .saturating_add(self.removed)
1393 .saturating_add(self.modified);
1394 if changed == 0 {
1395 return None;
1396 }
1397 let paths_complete = self.paths.len() == changed;
1398 let requires_full_scan = !paths_complete
1399 || self.folder_changed
1400 || self.paths.iter().any(|path| {
1401 watch_path_requires_full_scan(root, &root.join(repo_path_to_native(path)))
1402 });
1403 let exceeds_normal_read = changed > NORMAL_READ_REFRESH_MAX_PATHS
1404 || self.changed_bytes > NORMAL_READ_REFRESH_MAX_BYTES;
1405 let sample_paths = self
1406 .paths
1407 .iter()
1408 .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
1409 .cloned()
1410 .collect();
1411 Some(IndexFreshnessDelta {
1412 paths_complete,
1413 requires_full_scan,
1414 report: IndexRefreshRequired {
1415 project_root: lossless_project_root_display(root),
1416 worktree: None,
1417 status: IndexReadStatus::RefreshRequired,
1418 reason: if self.added == 0 && self.removed == 0 {
1419 IndexRefreshReason::SourceChanged
1420 } else {
1421 IndexRefreshReason::PathsChanged
1422 },
1423 scope: if requires_full_scan || exceeds_normal_read {
1424 IndexRefreshScope::Full
1425 } else {
1426 IndexRefreshScope::Incremental
1427 },
1428 changed,
1429 added: self.added,
1430 removed: self.removed,
1431 modified: self.modified,
1432 sample_paths,
1433 },
1434 paths: if paths_complete {
1435 self.paths
1436 .into_iter()
1437 .map(|path| root.join(repo_path_to_native(&path)))
1438 .collect()
1439 } else {
1440 HashSet::new()
1441 },
1442 })
1443 }
1444}
1445
1446fn verify_index_project_root(store: &AtlasStore, selected_root: &Path) -> Result<(), CliError> {
1448 let Some(indexed_root) = store.project_root_identity()? else {
1449 return Err(verification_incomplete(
1450 selected_root,
1451 IndexVerificationReason::ProjectIdentityUnavailable,
1452 &CliError::InvalidInput("index project root metadata is missing".to_string()),
1453 ));
1454 };
1455 let selected_root = CanonicalProjectRoot::from_path(selected_root).map_err(|source| {
1456 verification_incomplete(
1457 selected_root,
1458 IndexVerificationReason::SourceInspectionFailed,
1459 &CliError::InvalidInput(source.to_string()),
1460 )
1461 })?;
1462 if !store.project_root_identity_matches(&selected_root) {
1463 return Err(CliError::ProjectMismatch(Box::new(
1464 IndexProjectMismatch::from_native_roots(&selected_root, &indexed_root),
1465 )));
1466 }
1467 Ok(())
1468}
1469
1470fn verify_index_publication(
1472 store: &AtlasStore,
1473 plan: &ScanRuntimePlan,
1474) -> Result<IndexGeneration, CliError> {
1475 let expected_fingerprint = plan.publication_contract_fingerprint();
1476 let Some(publication) = store.index_publication()? else {
1477 return Err(verification_incomplete(
1478 &plan.root,
1479 IndexVerificationReason::PublicationIncomplete,
1480 &CliError::InvalidInput(
1481 "derived index publication state is missing; run one refresh".to_string(),
1482 ),
1483 ));
1484 };
1485 if publication.state == IndexPublicationState::Updating {
1486 return Err(verification_incomplete(
1487 &plan.root,
1488 IndexVerificationReason::PublicationIncomplete,
1489 &CliError::InvalidInput(
1490 "a prior derived index publication did not complete; run one refresh".to_string(),
1491 ),
1492 ));
1493 }
1494 if publication.generation == projectatlas_core::IndexGeneration::ZERO {
1495 return Err(verification_incomplete(
1496 &plan.root,
1497 IndexVerificationReason::PublicationIncomplete,
1498 &CliError::InvalidInput(
1499 "derived index has no complete publication generation; run one refresh".to_string(),
1500 ),
1501 ));
1502 }
1503 if publication.contract_fingerprint.as_deref() != Some(expected_fingerprint.as_str()) {
1504 return Err(verification_incomplete(
1505 &plan.root,
1506 IndexVerificationReason::PublicationContractMismatch,
1507 &CliError::InvalidInput(
1508 "derived index parser or scan-policy contract changed; run one refresh".to_string(),
1509 ),
1510 ));
1511 }
1512 Ok(publication.generation)
1513}
1514
1515pub(crate) fn publication_contract_matches(
1517 store: &AtlasStore,
1518 plan: &ScanRuntimePlan,
1519) -> Result<bool, CliError> {
1520 let Some(publication) = store.index_publication()? else {
1521 return Ok(false);
1522 };
1523 Ok(publication.state == IndexPublicationState::Complete
1524 && publication.generation != IndexGeneration::ZERO
1525 && publication.contract_fingerprint.as_deref()
1526 == Some(plan.publication_contract_fingerprint().as_str()))
1527}
1528
1529fn index_derivation_fingerprint(
1531 scan_options: &ScanOptions,
1532 text_options: TextIndexOptions,
1533 #[cfg(feature = "optional-parser-supervisor")]
1534 optional_parser_selection: &OptionalParserPackProjectSelection,
1535) -> String {
1536 index_derivation_fingerprint_for_contract(
1537 scan_options,
1538 text_options,
1539 #[cfg(feature = "optional-parser-supervisor")]
1540 optional_parser_selection,
1541 INDEX_DERIVATION_CONTRACT_VERSION,
1542 &semantic_resolution_contract_digest(),
1543 )
1544}
1545
1546fn index_derivation_fingerprint_for_contract(
1548 scan_options: &ScanOptions,
1549 text_options: TextIndexOptions,
1550 #[cfg(feature = "optional-parser-supervisor")]
1551 optional_parser_selection: &OptionalParserPackProjectSelection,
1552 contract_version: &str,
1553 semantic_resolution_digest: &str,
1554) -> String {
1555 let mut hasher = Hasher::new();
1556 hash_index_contract_value(&mut hasher, "contract_version", contract_version);
1557 hash_index_contract_value(
1558 &mut hasher,
1559 "language_registry_version",
1560 &LANGUAGE_CAPABILITY_REGISTRY_VERSION.to_string(),
1561 );
1562 hash_index_contract_value(
1563 &mut hasher,
1564 "accepted_language_set_version",
1565 &ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION.to_string(),
1566 );
1567 hash_index_contract_value(
1568 &mut hasher,
1569 "language_registry_digest",
1570 &language_registry_digest(),
1571 );
1572 hash_index_contract_value(
1573 &mut hasher,
1574 "accepted_language_set_digest",
1575 &accepted_language_capability_digest(),
1576 );
1577 hash_index_contract_value(
1578 &mut hasher,
1579 "semantic_resolution_contract_digest",
1580 semantic_resolution_digest,
1581 );
1582 for value in &scan_options.exclude_dir_names {
1583 hash_index_contract_value(&mut hasher, "exclude_dir_name", value);
1584 }
1585 for value in &scan_options.exclude_dir_suffixes {
1586 hash_index_contract_value(&mut hasher, "exclude_dir_suffix", value);
1587 }
1588 for value in &scan_options.exclude_path_prefixes {
1589 hash_index_contract_value(&mut hasher, "exclude_path_prefix", value);
1590 }
1591 for (selector, language) in &scan_options.language_overrides {
1592 hash_index_contract_value(&mut hasher, "language_override_selector", selector);
1593 hash_index_contract_value(&mut hasher, "language_override_target", language);
1594 }
1595 hash_index_contract_value(
1596 &mut hasher,
1597 "text_index_max_bytes",
1598 &text_options.max_bytes.to_string(),
1599 );
1600 #[cfg(feature = "optional-parser-supervisor")]
1601 hash_index_contract_value(
1602 &mut hasher,
1603 "optional_parser_selection",
1604 optional_parser_selection
1605 .selection_key()
1606 .map_or("inactive", |selection| selection.as_str()),
1607 );
1608 hasher.finalize().to_hex().to_string()
1609}
1610
1611#[cfg(test)]
1613fn revalidate_index_publication_inputs_controlled(
1614 store: &AtlasStore,
1615 plan: &ScanRuntimePlan,
1616 expected_purpose_import_fingerprint: Option<&str>,
1617 control: &IndexWorkControl,
1618) -> Result<(), CliError> {
1619 revalidate_index_publication_inputs_controlled_with_limits(
1620 store,
1621 plan,
1622 expected_purpose_import_fingerprint,
1623 control,
1624 PurposeImportLimits::default(),
1625 )
1626}
1627
1628#[cfg(test)]
1630fn revalidate_index_publication_inputs_controlled_with_limits(
1631 store: &AtlasStore,
1632 plan: &ScanRuntimePlan,
1633 expected_purpose_import_fingerprint: Option<&str>,
1634 control: &IndexWorkControl,
1635 purpose_limits: PurposeImportLimits,
1636) -> Result<(), CliError> {
1637 let staged_nodes = store
1638 .load_nodes()?
1639 .into_iter()
1640 .map(|indexed| indexed.node)
1641 .collect::<Vec<_>>();
1642 revalidate_staged_publication_inputs_controlled_with_limits(
1643 plan,
1644 &staged_nodes,
1645 expected_purpose_import_fingerprint,
1646 None,
1647 control,
1648 purpose_limits,
1649 )
1650}
1651
1652fn revalidate_staged_publication_inputs_controlled(
1654 plan: &ScanRuntimePlan,
1655 staged_nodes: &[Node],
1656 expected_purpose_import_fingerprint: Option<&str>,
1657 control: &IndexWorkControl,
1658) -> Result<(), CliError> {
1659 revalidate_staged_publication_inputs_controlled_with_limits(
1660 plan,
1661 staged_nodes,
1662 expected_purpose_import_fingerprint,
1663 None,
1664 control,
1665 PurposeImportLimits::default(),
1666 )
1667}
1668
1669fn revalidate_staged_publication_inputs_with_purpose_snapshot(
1671 plan: &ScanRuntimePlan,
1672 staged_nodes: &[Node],
1673 purpose_import: Option<&PurposeImportSnapshot>,
1674 control: &IndexWorkControl,
1675) -> Result<(), CliError> {
1676 revalidate_staged_publication_inputs_controlled_with_limits(
1677 plan,
1678 staged_nodes,
1679 purpose_import.map(|snapshot| snapshot.fingerprint.as_str()),
1680 purpose_import.map(|snapshot| snapshot.records.as_slice()),
1681 control,
1682 PurposeImportLimits::default(),
1683 )
1684}
1685
1686fn revalidate_staged_publication_inputs_controlled_with_limits(
1688 plan: &ScanRuntimePlan,
1689 staged_nodes: &[Node],
1690 expected_purpose_import_fingerprint: Option<&str>,
1691 reusable_purpose_records: Option<&[atlas_map::ImportedPurposeRecord]>,
1692 control: &IndexWorkControl,
1693 purpose_limits: PurposeImportLimits,
1694) -> Result<(), CliError> {
1695 control.check(IndexWorkStage::Publication)?;
1696 let current_plan = plan
1697 .reload_controlled_with_limits(control, purpose_limits)
1698 .map_err(|source| publication_input_error(&plan.root, source))?;
1699 control.check(IndexWorkStage::Publication)?;
1700 let staged_fingerprint = plan.publication_contract_fingerprint();
1701 let current_fingerprint = current_plan.publication_contract_fingerprint();
1702 if staged_fingerprint != current_fingerprint {
1703 return Err(verification_incomplete(
1704 &plan.root,
1705 IndexVerificationReason::PublicationContractMismatch,
1706 &CliError::InvalidInput(
1707 "derived index policy changed while publication was being built; retry the refresh"
1708 .to_string(),
1709 ),
1710 ));
1711 }
1712 let current_nodes = scan_repo_controlled(
1713 ¤t_plan.root,
1714 ¤t_plan.scan_options,
1715 ScanLimits::default(),
1716 control,
1717 )
1718 .map_err(|source| source_inspection_error(¤t_plan.root, source))?;
1719 verify_source_nodes_match(¤t_plan.root, ¤t_nodes, staged_nodes)?;
1720 if let Some(expected_fingerprint) = expected_purpose_import_fingerprint {
1721 let current_fingerprint = if let Some(records) = reusable_purpose_records {
1722 current_plan
1723 .purpose_import_fingerprint_for_records_controlled_with_limits(
1724 records,
1725 control,
1726 purpose_limits,
1727 )
1728 .map_err(|source| publication_input_error(&plan.root, source))?
1729 } else {
1730 current_plan
1731 .purpose_import_snapshot_controlled_with_limits(
1732 ¤t_nodes,
1733 control,
1734 purpose_limits,
1735 )
1736 .map_err(|source| publication_input_error(&plan.root, source))?
1737 .fingerprint
1738 };
1739 if current_fingerprint != expected_fingerprint {
1740 return Err(verification_incomplete(
1741 &plan.root,
1742 IndexVerificationReason::PublicationContractMismatch,
1743 &CliError::InvalidInput(
1744 "purpose-import inputs changed while publication was being built; retry the refresh"
1745 .to_string(),
1746 ),
1747 ));
1748 }
1749 }
1750 control.check(IndexWorkStage::Publication)?;
1751 Ok(())
1752}
1753
1754fn publication_input_error(root: &Path, source: CliError) -> CliError {
1756 match source {
1757 source @ CliError::IndexWork(_) => source,
1758 other => verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &other),
1759 }
1760}
1761
1762fn source_inspection_error(root: &Path, source: FsError) -> CliError {
1764 match source {
1765 FsError::IndexWork(failure) => failure.into(),
1766 FsError::RepositoryBoundary { .. } => {
1767 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1768 project_root: lossless_project_root_display(root),
1769 worktree: None,
1770 status: IndexReadStatus::VerificationIncomplete,
1771 reason: IndexVerificationReason::PolicyUnavailable,
1772 scope: IndexRefreshScope::Full,
1773 message: source.to_string(),
1774 }))
1775 }
1776 other => CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1777 project_root: lossless_project_root_display(root),
1778 worktree: None,
1779 status: IndexReadStatus::VerificationIncomplete,
1780 reason: IndexVerificationReason::SourceInspectionFailed,
1781 scope: IndexRefreshScope::Full,
1782 message: other.to_string(),
1783 })),
1784 }
1785}
1786
1787fn complete_index_publication(
1789 publication: IndexPublicationGuard<'_>,
1790 control: &IndexWorkControl,
1791) -> Result<(), CliError> {
1792 control.check(IndexWorkStage::Publication)?;
1793 publication.complete()?;
1794 Ok(())
1795}
1796
1797fn hash_index_contract_value(hasher: &mut Hasher, field: &str, value: &str) {
1799 hasher.update(field.as_bytes());
1800 hasher.update(&[0]);
1801 hasher.update(value.as_bytes());
1802 hasher.update(&[0xff]);
1803}
1804
1805fn hash_index_contract_native_path(hasher: &mut Hasher, field: &str, path: &Path) {
1807 hasher.update(field.as_bytes());
1808 hasher.update(&[0]);
1809 hasher.update(path.as_os_str().as_encoded_bytes());
1810 hasher.update(&[0xff]);
1811}
1812
1813fn verification_incomplete(
1815 root: &Path,
1816 reason: IndexVerificationReason,
1817 source: &CliError,
1818) -> CliError {
1819 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1820 project_root: lossless_project_root_display(root),
1821 worktree: None,
1822 status: IndexReadStatus::VerificationIncomplete,
1823 reason,
1824 scope: IndexRefreshScope::Full,
1825 message: source.to_string(),
1826 }))
1827}
1828
1829fn same_indexed_source(current: &Node, indexed: &Node) -> bool {
1831 current.path == indexed.path
1832 && current.kind == indexed.kind
1833 && current.parent_path == indexed.parent_path
1834 && current.extension == indexed.extension
1835 && current.language == indexed.language
1836 && current.size_bytes == indexed.size_bytes
1837 && current.content_hash == indexed.content_hash
1838}
1839
1840fn source_changed_during_derivation(root: &Path, path: &str) -> CliError {
1842 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
1843 project_root: lossless_project_root_display(root),
1844 worktree: None,
1845 status: IndexReadStatus::RefreshRequired,
1846 reason: IndexRefreshReason::SourceChanged,
1847 scope: IndexRefreshScope::Full,
1848 changed: 1,
1849 added: 0,
1850 removed: 0,
1851 modified: 1,
1852 sample_paths: vec![path.to_string()],
1853 }))
1854}
1855
1856impl<'a> PurposeInputReader<'a> {
1857 fn new(
1859 plan: &ScanRuntimePlan,
1860 control: &'a IndexWorkControl,
1861 limits: PurposeImportLimits,
1862 ) -> Self {
1863 let mut complete_paths = BTreeSet::new();
1864 if let Some(path) = &plan.selected_config_path {
1865 complete_paths.insert(path.clone());
1866 }
1867 if let Some(config) = &plan.config {
1868 complete_paths.insert(config.map_path.clone());
1869 complete_paths.insert(config.nonsource_files_path.clone());
1870 }
1871 Self::for_complete_paths(
1872 control,
1873 limits,
1874 complete_paths,
1875 plan.config.as_ref().map_or_else(
1876 || ".purpose".to_string(),
1877 |config| config.purpose_filename().to_string(),
1878 ),
1879 )
1880 }
1881
1882 fn for_complete_paths(
1884 control: &'a IndexWorkControl,
1885 limits: PurposeImportLimits,
1886 complete_paths: BTreeSet<PathBuf>,
1887 purpose_filename: String,
1888 ) -> Self {
1889 Self {
1890 control,
1891 limits,
1892 complete_paths,
1893 purpose_filename,
1894 complete_digests: BTreeMap::new(),
1895 }
1896 }
1897
1898 fn read_text(&mut self, path: &Path) -> Result<String, CliError> {
1900 self.control.check(IndexWorkStage::Publication)?;
1901 let is_complete = self.complete_paths.contains(path)
1902 || path
1903 .file_name()
1904 .is_some_and(|name| name == self.purpose_filename.as_str());
1905 let file_limit = if is_complete {
1906 self.limits.complete_file_bytes
1907 } else {
1908 self.limits.header_bytes
1909 };
1910 let mut file = fs::File::open(path).map_err(|source| CliError::Io {
1911 path: path.to_path_buf(),
1912 source,
1913 })?;
1914 let bytes = self.read_bytes(path, &mut file, file_limit, is_complete)?;
1915 if is_complete {
1916 self.complete_digests.insert(
1917 path.to_path_buf(),
1918 blake3::hash(&bytes).to_hex().to_string(),
1919 );
1920 }
1921 match String::from_utf8(bytes) {
1922 Ok(content) => Ok(content),
1923 Err(source) if !is_complete && source.utf8_error().error_len().is_some() => {
1924 Ok(String::new())
1925 }
1926 Err(source) if !is_complete && source.utf8_error().error_len().is_none() => {
1927 let valid_up_to = source.utf8_error().valid_up_to();
1928 String::from_utf8(source.into_bytes()[..valid_up_to].to_vec()).map_err(|source| {
1929 CliError::InvalidInput(format!(
1930 "purpose header input is not valid UTF-8 for {}: {source}",
1931 normalize_native_path_display(path)
1932 ))
1933 })
1934 }
1935 Err(source) => Err(CliError::InvalidInput(format!(
1936 "purpose input is not valid UTF-8 for {}: {source}",
1937 normalize_native_path_display(path)
1938 ))),
1939 }
1940 }
1941
1942 fn read_bytes<R: Read>(
1944 &mut self,
1945 path: &Path,
1946 reader: &mut R,
1947 file_limit: u64,
1948 require_complete: bool,
1949 ) -> Result<Vec<u8>, CliError> {
1950 let initial_capacity = usize::try_from(file_limit)
1951 .unwrap_or(usize::MAX)
1952 .min(CONTROLLED_SOURCE_READ_BUFFER_BYTES);
1953 let mut bytes = Vec::with_capacity(initial_capacity);
1954 let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
1955 loop {
1956 self.control.check(IndexWorkStage::Publication)?;
1957 let file_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
1958 let remaining = file_limit.saturating_sub(file_bytes);
1959 if remaining == 0 {
1960 if !require_complete {
1961 break;
1962 }
1963 let read = reader
1964 .read(&mut buffer[..1])
1965 .map_err(|source| CliError::Io {
1966 path: path.to_path_buf(),
1967 source,
1968 })?;
1969 if read == 0 {
1970 break;
1971 }
1972 return Err(IndexWorkFailure::resource_limit(
1973 IndexWorkStage::Publication,
1974 IndexWorkResource::PurposeBytes,
1975 file_limit,
1976 file_limit.saturating_add(1),
1977 )
1978 .into());
1979 }
1980 let read_limit = usize::try_from(remaining)
1981 .unwrap_or(usize::MAX)
1982 .min(buffer.len());
1983 let read = reader
1984 .read(&mut buffer[..read_limit])
1985 .map_err(|source| CliError::Io {
1986 path: path.to_path_buf(),
1987 source,
1988 })?;
1989 if read == 0 {
1990 break;
1991 }
1992 self.control.consume_purpose_bytes(
1993 self.limits.total_bytes,
1994 u64::try_from(read).unwrap_or(u64::MAX),
1995 )?;
1996 bytes.extend_from_slice(&buffer[..read]);
1997 }
1998 self.control.check(IndexWorkStage::Publication)?;
1999 Ok(bytes)
2000 }
2001
2002 fn complete_digest(&self, path: &Path) -> Option<&str> {
2004 self.complete_digests.get(path).map(String::as_str)
2005 }
2006}
2007
2008impl ScanRuntimePlan {
2009 pub(crate) fn for_path(
2011 config_path: Option<&Path>,
2012 path: &Path,
2013 text_index_max_bytes: Option<u64>,
2014 ) -> Result<Self, CliError> {
2015 let control = standalone_index_work_control();
2016 Self::for_path_controlled(config_path, path, text_index_max_bytes, &control)
2017 }
2018
2019 pub(crate) fn for_path_controlled(
2021 config_path: Option<&Path>,
2022 path: &Path,
2023 text_index_max_bytes: Option<u64>,
2024 control: &IndexWorkControl,
2025 ) -> Result<Self, CliError> {
2026 Self::for_path_controlled_with_limits(
2027 config_path,
2028 path,
2029 text_index_max_bytes,
2030 control,
2031 PurposeImportLimits::default(),
2032 )
2033 }
2034
2035 fn for_path_controlled_with_limits(
2037 config_path: Option<&Path>,
2038 path: &Path,
2039 text_index_max_bytes: Option<u64>,
2040 control: &IndexWorkControl,
2041 purpose_limits: PurposeImportLimits,
2042 ) -> Result<Self, CliError> {
2043 control.check(IndexWorkStage::Publication)?;
2044 let root = canonical_source_project_root(path)?;
2045 let selected_config_path = selected_scan_import_config_path(config_path, &root)?;
2046 let config = if let Some(path) = selected_config_path.as_deref() {
2047 let mut complete_paths = BTreeSet::new();
2048 complete_paths.insert(path.to_path_buf());
2049 let mut reader = PurposeInputReader::for_complete_paths(
2050 control,
2051 purpose_limits,
2052 complete_paths,
2053 ".purpose".to_string(),
2054 );
2055 let text = reader.read_text(path)?;
2056 let config = load_atlas_config_from_text(path, &text)?;
2057 let config_root = canonical_project_root(&config.root)?;
2058 if config_root != root {
2059 return Err(config_root_mismatch_error(path, &config_root, &root));
2060 }
2061 Some(config)
2062 } else {
2063 None
2064 };
2065 control.check(IndexWorkStage::Publication)?;
2066 let scan_options = config.as_ref().map_or_else(
2067 ScanOptions::default,
2068 atlas_map::AtlasMapConfig::scan_options,
2069 );
2070 let text_options = text_index_options(config.as_ref(), text_index_max_bytes);
2071 #[cfg(feature = "optional-parser-supervisor")]
2072 let optional_parser_selection =
2073 OptionalParserPackLifecycle::new(&root, None)?.derive_project_selection()?;
2074 #[cfg(feature = "optional-parser-supervisor")]
2075 let scan_options = {
2076 let mut scan_options = scan_options;
2077 scan_options.admit_optional_languages =
2078 optional_parser_selection.selection_key().is_some();
2079 scan_options
2080 };
2081 Ok(Self {
2082 root,
2083 config,
2084 selected_config_path,
2085 config_path_override: config_path.map(Path::to_path_buf),
2086 scan_options,
2087 text_options,
2088 text_index_max_bytes_override: text_index_max_bytes,
2089 #[cfg(feature = "optional-parser-supervisor")]
2090 optional_parser_selection,
2091 })
2092 }
2093
2094 fn reload(&self) -> Result<Self, CliError> {
2096 Self::for_path(
2097 self.config_path_override.as_deref(),
2098 &self.root,
2099 self.text_index_max_bytes_override,
2100 )
2101 }
2102
2103 fn reload_controlled(&self, control: &IndexWorkControl) -> Result<Self, CliError> {
2105 self.reload_controlled_with_limits(control, PurposeImportLimits::default())
2106 }
2107
2108 fn reload_controlled_with_limits(
2110 &self,
2111 control: &IndexWorkControl,
2112 purpose_limits: PurposeImportLimits,
2113 ) -> Result<Self, CliError> {
2114 Self::for_path_controlled_with_limits(
2115 self.config_path_override.as_deref(),
2116 &self.root,
2117 self.text_index_max_bytes_override,
2118 control,
2119 purpose_limits,
2120 )
2121 }
2122
2123 fn publication_contract_fingerprint(&self) -> String {
2128 let configured_text_options = text_index_options(self.config.as_ref(), None);
2129 index_derivation_fingerprint(
2130 &self.scan_options,
2131 configured_text_options,
2132 #[cfg(feature = "optional-parser-supervisor")]
2133 &self.optional_parser_selection,
2134 )
2135 }
2136
2137 fn purpose_import_snapshot_controlled(
2139 &self,
2140 nodes: &[Node],
2141 control: &IndexWorkControl,
2142 ) -> Result<PurposeImportSnapshot, CliError> {
2143 self.purpose_import_snapshot_controlled_with_limits(
2144 nodes,
2145 control,
2146 PurposeImportLimits::default(),
2147 )
2148 }
2149
2150 fn purpose_import_snapshot_controlled_with_limits(
2152 &self,
2153 nodes: &[Node],
2154 control: &IndexWorkControl,
2155 limits: PurposeImportLimits,
2156 ) -> Result<PurposeImportSnapshot, CliError> {
2157 control.check(IndexWorkStage::Publication)?;
2158 let mut hasher = Hasher::new();
2159 hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2160 let Some(config) = self.config.as_ref() else {
2161 hash_index_contract_value(&mut hasher, "selected_config", "absent");
2162 return Ok(PurposeImportSnapshot {
2163 records: Vec::new(),
2164 fingerprint: hasher.finalize().to_hex().to_string(),
2165 });
2166 };
2167 let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2168 CliError::InvalidInput(
2169 "purpose import has normalized configuration without a selected config path"
2170 .to_string(),
2171 )
2172 })?;
2173 if u64::try_from(nodes.len()).unwrap_or(u64::MAX) > limits.records {
2174 return Err(IndexWorkFailure::resource_limit(
2175 IndexWorkStage::Publication,
2176 IndexWorkResource::PurposeRecords,
2177 limits.records,
2178 u64::try_from(nodes.len()).unwrap_or(u64::MAX),
2179 )
2180 .into());
2181 }
2182 let mut reader = PurposeInputReader::new(self, control, limits);
2183 hash_publication_input_file_controlled(
2184 &mut hasher,
2185 "selected_config",
2186 selected_config_path,
2187 &mut reader,
2188 )?;
2189 let records = atlas_map::imported_purpose_records_from_nodes(config, nodes, &mut |path| {
2190 reader.read_text(path)
2191 })?;
2192 let fingerprint = finish_purpose_import_fingerprint_controlled(
2193 &mut hasher,
2194 config,
2195 &records,
2196 &mut reader,
2197 control,
2198 limits,
2199 )?;
2200 Ok(PurposeImportSnapshot {
2201 records,
2202 fingerprint,
2203 })
2204 }
2205
2206 fn purpose_import_fingerprint_for_records_controlled_with_limits(
2208 &self,
2209 records: &[atlas_map::ImportedPurposeRecord],
2210 control: &IndexWorkControl,
2211 limits: PurposeImportLimits,
2212 ) -> Result<String, CliError> {
2213 control.check(IndexWorkStage::Publication)?;
2214 let mut hasher = Hasher::new();
2215 hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
2216 let Some(config) = self.config.as_ref() else {
2217 hash_index_contract_value(&mut hasher, "selected_config", "absent");
2218 return Ok(hasher.finalize().to_hex().to_string());
2219 };
2220 let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
2221 CliError::InvalidInput(
2222 "purpose import has normalized configuration without a selected config path"
2223 .to_string(),
2224 )
2225 })?;
2226 let mut reader = PurposeInputReader::new(self, control, limits);
2227 hash_publication_input_file_controlled(
2228 &mut hasher,
2229 "selected_config",
2230 selected_config_path,
2231 &mut reader,
2232 )?;
2233 finish_purpose_import_fingerprint_controlled(
2234 &mut hasher,
2235 config,
2236 records,
2237 &mut reader,
2238 control,
2239 limits,
2240 )
2241 }
2242}
2243
2244fn finish_purpose_import_fingerprint_controlled(
2246 hasher: &mut Hasher,
2247 config: &atlas_map::AtlasMapConfig,
2248 records: &[atlas_map::ImportedPurposeRecord],
2249 reader: &mut PurposeInputReader<'_>,
2250 control: &IndexWorkControl,
2251 limits: PurposeImportLimits,
2252) -> Result<String, CliError> {
2253 let record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
2254 if record_count > limits.records {
2255 return Err(IndexWorkFailure::resource_limit(
2256 IndexWorkStage::Publication,
2257 IndexWorkResource::PurposeRecords,
2258 limits.records,
2259 record_count,
2260 )
2261 .into());
2262 }
2263 hash_publication_input_file_controlled(hasher, "legacy_map", &config.map_path, reader)?;
2264 hash_publication_input_file_controlled(
2265 hasher,
2266 "nonsource_purposes",
2267 &config.nonsource_files_path,
2268 reader,
2269 )?;
2270 for record in records {
2271 control.check(IndexWorkStage::Publication)?;
2272 hash_index_contract_value(hasher, "purpose_path", &record.path);
2273 hash_index_contract_value(hasher, "purpose_summary", &record.summary);
2274 }
2275 Ok(hasher.finalize().to_hex().to_string())
2276}
2277
2278fn selected_scan_import_config_path(
2280 config_path: Option<&Path>,
2281 root: &Path,
2282) -> Result<Option<PathBuf>, CliError> {
2283 if let Some(config_path) = config_path {
2284 return absolute_path(config_path).map(Some);
2285 }
2286 Ok(config_candidates_for_root(root)
2287 .into_iter()
2288 .find(|candidate| candidate.exists()))
2289}
2290
2291fn hash_publication_input_file_controlled(
2293 hasher: &mut Hasher,
2294 role: &str,
2295 path: &Path,
2296 reader: &mut PurposeInputReader<'_>,
2297) -> Result<(), CliError> {
2298 hash_index_contract_native_path(hasher, role, path);
2299 if !path.exists() {
2300 hash_index_contract_value(hasher, "input_state", "missing");
2301 return Ok(());
2302 }
2303 if reader.complete_digest(path).is_none() {
2304 let _ = reader.read_text(path)?;
2305 }
2306 let digest = reader.complete_digest(path).ok_or_else(|| {
2307 CliError::InvalidInput(format!(
2308 "purpose publication input was not read completely: {}",
2309 normalize_native_path_display(path)
2310 ))
2311 })?;
2312 hash_index_contract_value(hasher, "input_state", "present");
2313 hash_index_contract_value(hasher, "input_digest", digest);
2314 Ok(())
2315}
2316
2317#[derive(Debug, Serialize)]
2319pub(crate) struct ScanReport {
2320 pub(crate) overview: Overview,
2322 pub(crate) purpose_import: PurposeImportReport,
2324 pub(crate) text_index: TextIndexReport,
2326 pub(crate) structural_summaries: StructuralSummaryReport,
2328 pub(crate) symbols: SymbolBuildReport,
2330}
2331
2332#[derive(Debug, Default, Serialize)]
2334pub(crate) struct PurposeImportReport {
2335 pub(crate) imported: usize,
2337 pub(crate) skipped_stale: usize,
2339 pub(crate) skipped_existing: usize,
2341}
2342
2343pub(crate) struct InitBootstrapOptions {
2345 pub(crate) no_scan: bool,
2347 pub(crate) force_rescan: bool,
2349 pub(crate) text_index_max_bytes: Option<u64>,
2351}
2352
2353#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2355#[serde(rename_all = "snake_case")]
2356pub(crate) enum InitPhaseStatus {
2357 Created,
2359 Exists,
2361 Verified,
2363 Skipped,
2365 Failed,
2367}
2368
2369#[derive(Debug, Serialize)]
2371pub(crate) struct InitSetupReport {
2372 pub(crate) ok: bool,
2374 pub(crate) root: Option<String>,
2376 pub(crate) project_dir: InitPathStatus,
2378 pub(crate) config: InitPathStatus,
2380 pub(crate) nonsource_files: InitPathStatus,
2382 pub(crate) db: InitPathStatus,
2384 pub(crate) host_configs: Vec<InitHostConfigStatus>,
2386 pub(crate) scan: InitScanPhase,
2388 #[serde(skip_serializing_if = "Option::is_none")]
2390 pub(crate) hydration: Option<InitHydrationPhase>,
2391 pub(crate) purpose_handoff: PurposeCuratorHandoff,
2393 pub(crate) next_steps: Vec<String>,
2395}
2396
2397#[derive(Debug, Serialize)]
2399pub(crate) struct InitPathStatus {
2400 pub(crate) status: InitPhaseStatus,
2402 pub(crate) path: Option<String>,
2404}
2405
2406#[derive(Debug, Serialize)]
2408pub(crate) struct InitHostConfigStatus {
2409 pub(crate) harness: &'static str,
2411 pub(crate) status: InitPhaseStatus,
2413 pub(crate) path: Option<String>,
2415 #[serde(skip_serializing_if = "Option::is_none")]
2417 pub(crate) error: Option<String>,
2418}
2419
2420#[derive(Debug, Serialize)]
2422pub(crate) struct InitScanPhase {
2423 pub(crate) status: InitPhaseStatus,
2425 pub(crate) requested: bool,
2427 pub(crate) force_rescan: bool,
2429 pub(crate) report: Option<ScanReport>,
2431 #[serde(skip_serializing_if = "Option::is_none")]
2433 pub(crate) error: Option<String>,
2434}
2435
2436#[derive(Debug, Serialize)]
2438pub(crate) struct InitHydrationPhase {
2439 pub(crate) status: InitHydrationStatus,
2441 #[serde(skip_serializing_if = "Option::is_none")]
2443 pub(crate) source_root: Option<String>,
2444 #[serde(skip_serializing_if = "Option::is_none")]
2446 pub(crate) source_project_instance_id: Option<String>,
2447 #[serde(skip_serializing_if = "Option::is_none")]
2449 pub(crate) target_project_instance_id: Option<String>,
2450 #[serde(skip_serializing_if = "Option::is_none")]
2452 pub(crate) baseline_generation: Option<u64>,
2453 #[serde(skip_serializing_if = "Option::is_none")]
2455 pub(crate) reconciled_generation: Option<u64>,
2456 #[serde(skip_serializing_if = "Option::is_none")]
2458 pub(crate) fallback_reason: Option<String>,
2459}
2460
2461#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2463#[serde(rename_all = "snake_case")]
2464pub(crate) enum InitHydrationStatus {
2465 Existing,
2467 Hydrated,
2469 Fallback,
2471}
2472
2473#[derive(Debug, Serialize)]
2475#[allow(
2476 clippy::struct_excessive_bools,
2477 reason = "serialized host policy exposes independent capabilities and guarantees"
2478)]
2479pub(crate) struct PurposeCuratorHandoff {
2480 pub(crate) agent_harness_expected: bool,
2482 pub(crate) execution_owner: &'static str,
2484 pub(crate) recommended_subagent_reasoning: &'static str,
2486 pub(crate) main_agent_fallback: bool,
2488 pub(crate) server_started_curator: bool,
2490 pub(crate) silent_on_success: bool,
2492 pub(crate) queue: PurposeCurationPage,
2494 pub(crate) instructions: Vec<String>,
2496}
2497
2498pub(crate) fn canonical_project_root(root: &Path) -> Result<PathBuf, CliError> {
2500 root.canonicalize().map_err(|source| CliError::Io {
2501 path: root.to_path_buf(),
2502 source,
2503 })
2504}
2505
2506pub(crate) fn lossless_project_root_display(root: &Path) -> Option<String> {
2508 CanonicalProjectRoot::from_path(root)
2509 .ok()
2510 .and_then(|root| root.display_string().ok())
2511}
2512
2513pub(crate) fn lossless_native_path_display(path: &Path) -> Option<String> {
2515 projectatlas_core::lossless_native_path_display(path).ok()
2516}
2517
2518pub(crate) fn canonical_source_project_root(root: &Path) -> Result<PathBuf, CliError> {
2520 match projectatlas_fs::worktree::discover_repository_structure(root)? {
2521 RepositoryStructure::NonGit { selected_root } => Ok(selected_root),
2522 RepositoryStructure::Git(repository) => match repository.selection {
2523 GitRepositorySelection::Worktree { root, .. }
2524 | GitRepositorySelection::CommonManager {
2525 source_selection: GitManagerSourceSelection::Unambiguous { root },
2526 } => Ok(root),
2527 GitRepositorySelection::CommonManager { .. } => Err(CliError::WorktreeRequired(
2528 Box::new(ProjectWorktreeRequired {
2529 project_root: lossless_project_root_display(&repository.common_directory),
2530 status: IndexReadStatus::WorktreeRequired,
2531 }),
2532 )),
2533 },
2534 RepositoryStructure::InvalidGit {
2535 selected_root,
2536 issue,
2537 } => Err(CliError::InvalidInput(format!(
2538 "invalid structural Git evidence for '{}': {:?} at '{}'",
2539 normalize_native_path_display(selected_root),
2540 issue.kind,
2541 normalize_native_path_display(issue.path)
2542 ))),
2543 }
2544}
2545
2546pub(crate) fn index_init_required(root: &Path, database: &Path) -> CliError {
2548 CliError::InitRequired(Box::new(IndexInitRequired {
2549 project_root: lossless_project_root_display(root),
2550 database: lossless_native_path_display(database),
2551 worktree: None,
2552 status: IndexReadStatus::InitRequired,
2553 }))
2554}
2555
2556pub(crate) fn load_scan_import_config(
2558 config_path: Option<&Path>,
2559 scan_path: &Path,
2560) -> Result<Option<atlas_map::AtlasMapConfig>, CliError> {
2561 if let Some(config_path) = config_path {
2562 return Ok(Some(load_atlas_config(Some(config_path))?));
2563 }
2564 let project_config = scan_path.join(".projectatlas").join("config.toml");
2565 if project_config.exists() {
2566 return Ok(Some(load_atlas_config(Some(&project_config))?));
2567 }
2568 let flat_config = scan_path.join("projectatlas.toml");
2569 if flat_config.exists() {
2570 return Ok(Some(load_atlas_config(Some(&flat_config))?));
2571 }
2572 Ok(None)
2573}
2574
2575pub(crate) fn preflight_managed_database(
2579 path: &Path,
2580 root: &Path,
2581) -> Result<Option<crate::project_state::ProjectStateGuard>, CliError> {
2582 crate::project_state::ProjectStateGuard::preflight_database_path(path, root).map_err(|source| {
2583 CliError::Io {
2584 path: path.to_path_buf(),
2585 source,
2586 }
2587 })
2588}
2589
2590pub(crate) fn admitted_database_path<'a>(
2592 path: &'a Path,
2593 admission: Option<&'a crate::project_state::ProjectStateGuard>,
2594) -> &'a Path {
2595 admission
2596 .and_then(crate::project_state::ProjectStateGuard::sqlite_path)
2597 .unwrap_or(path)
2598}
2599
2600pub(crate) fn preflight_managed_reset(
2602 path: &Path,
2603 root: &Path,
2604) -> Result<Option<crate::project_state::ProjectStateGuard>, CliError> {
2605 crate::project_state::ProjectStateGuard::preflight_database_reset_path(path, root).map_err(
2606 |source| CliError::Io {
2607 path: path.to_path_buf(),
2608 source,
2609 },
2610 )
2611}
2612
2613pub(crate) fn open_atlas_store_for_project(
2615 path: &Path,
2616 root: &Path,
2617) -> Result<AtlasStore, CliError> {
2618 open_atlas_store_for_project_with_location_validator(path, root, validate_database_location)
2619}
2620
2621pub(crate) fn open_existing_atlas_store_for_project(
2623 path: &Path,
2624 root: &Path,
2625) -> Result<AtlasStore, CliError> {
2626 let admission = preflight_managed_database(path, root)?;
2627 let path = admitted_database_path(path, admission.as_ref());
2628 AtlasStore::open_existing_for_project(path, root).map_err(|source| {
2629 if matches!(source, projectatlas_db::DbError::InitializationRequired) {
2630 index_init_required(root, path)
2631 } else {
2632 project_store_error(source)
2633 }
2634 })
2635}
2636
2637fn open_atlas_store_for_project_with_location_validator<F>(
2639 path: &Path,
2640 root: &Path,
2641 validate_location: F,
2642) -> Result<AtlasStore, CliError>
2643where
2644 F: FnOnce(&Path) -> projectatlas_db::DbResult<()>,
2645{
2646 let admission = preflight_managed_database(path, root)?;
2647 let path = admitted_database_path(path, admission.as_ref());
2648 validate_location(path).map_err(project_store_error)?;
2649 ensure_parent_dir(path)?;
2650 AtlasStore::open_for_project(path, root).map_err(project_store_error)
2651}
2652
2653pub(crate) fn open_atlas_store_read_only_for_project(
2655 path: &Path,
2656 root: &Path,
2657) -> Result<AtlasStore, CliError> {
2658 let admission = preflight_managed_database(path, root)?;
2659 let path = admitted_database_path(path, admission.as_ref());
2660 AtlasStore::open_read_only_for_project(path, root).map_err(|source| {
2661 if matches!(source, projectatlas_db::DbError::InitializationRequired) {
2662 index_init_required(root, path)
2663 } else {
2664 project_store_error(source)
2665 }
2666 })
2667}
2668
2669pub(crate) fn project_store_error(source: projectatlas_db::DbError) -> CliError {
2671 match source {
2672 projectatlas_db::DbError::ProjectRootMismatch {
2673 expected,
2674 found,
2675 identities,
2676 } => CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
2677 status: IndexReadStatus::ProjectMismatch,
2678 worktree: None,
2679 selected_project_root: identities
2680 .as_ref()
2681 .and_then(|identities| identities.expected.display_string().ok()),
2682 indexed_project_root: identities
2683 .as_ref()
2684 .and_then(|identities| identities.found.display_string().ok()),
2685 diagnostic: Some(format!(
2686 "database project root {found:?} does not match selected root {expected:?}"
2687 )),
2688 })),
2689 other => CliError::Db(other),
2690 }
2691}
2692
2693pub(crate) fn init_config_path(root: &Path, explicit: Option<&Path>) -> PathBuf {
2695 if let Some(config_path) = explicit {
2696 return if config_path.is_absolute() {
2697 config_path.to_path_buf()
2698 } else {
2699 root.join(config_path)
2700 };
2701 }
2702 let nested_config = root.join(".projectatlas").join("config.toml");
2703 if nested_config.exists() {
2704 return nested_config;
2705 }
2706 let flat_config = root.join("projectatlas.toml");
2707 if flat_config.exists() {
2708 return flat_config;
2709 }
2710 nested_config
2711}
2712
2713pub(crate) fn preflight_generated_state(
2715 root: &Path,
2716 db_path: &Path,
2717 config_path: &Path,
2718) -> Result<crate::project_state::ProjectStateGuard, CliError> {
2719 crate::project_state::ProjectStateGuard::preflight(root, Some(db_path), Some(config_path))
2720 .map_err(|source| CliError::Io {
2721 path: root.join(".projectatlas"),
2722 source,
2723 })
2724}
2725
2726pub(crate) fn run_init_bootstrap(
2730 root: &Path,
2731 db_path: &Path,
2732 config_path: Option<&Path>,
2733 options: &InitBootstrapOptions,
2734) -> Result<InitSetupReport, CliError> {
2735 let root = canonical_source_project_root(root)?;
2736 let config_file = init_config_path(&root, config_path);
2737 let state_guard = preflight_generated_state(&root, db_path, &config_file)?;
2738 let db_path = state_guard.sqlite_path().unwrap_or(db_path);
2739 if db_path.is_file() {
2740 AtlasStore::repair_missing_project_root_identity(db_path, &root)
2741 .map_err(project_store_error)?;
2742 }
2743 preflight_existing_project_binding(db_path, &root)?;
2744 let project_dir = root.join(".projectatlas");
2745 let nonsource_file = project_dir.join("projectatlas-nonsource-files.toon");
2746 let project_dir_existed = project_dir.exists();
2747 let config_existed = config_file.exists();
2748 let nonsource_existed = nonsource_file.exists();
2749 let db_existed = db_path.exists();
2750
2751 init_project_with_config(&root, Some(&config_file))?;
2752 state_guard.verify().map_err(|source| CliError::Io {
2753 path: root.join(".projectatlas"),
2754 source,
2755 })?;
2756 let mut store = open_atlas_store_for_project(db_path, &root)?;
2757
2758 let mut ok = true;
2759 let (scan_status, scan_report, scan_error) = if options.no_scan {
2760 (InitPhaseStatus::Skipped, None, None)
2761 } else {
2762 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
2763 let control = index_work_control(&symbol_options);
2764 match ScanRuntimePlan::for_path_controlled(
2765 config_path,
2766 &root,
2767 options.text_index_max_bytes,
2768 &control,
2769 )
2770 .and_then(|plan| run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control))
2771 {
2772 Ok(report) => (InitPhaseStatus::Verified, Some(report), None),
2773 Err(error) => {
2774 ok = false;
2775 (InitPhaseStatus::Failed, None, Some(error.to_string()))
2776 }
2777 }
2778 };
2779
2780 let purpose_query = HealthQuery {
2781 start_index: 0,
2782 limit: DEFAULT_HEALTH_LIMIT,
2783 category: None,
2784 severity: None,
2785 path_prefix: None,
2786 summary_only: false,
2787 scope: HealthScope::purpose_default(),
2788 };
2789 let purpose_queue = purpose_curation_page(&store, &purpose_query, "project-init")?;
2790 let next_steps = init_next_steps(options.no_scan, scan_error.is_some(), purpose_queue.total);
2791
2792 Ok(InitSetupReport {
2793 ok,
2794 root: lossless_project_root_display(&root),
2795 project_dir: InitPathStatus {
2796 status: init_path_status(project_dir_existed),
2797 path: lossless_native_path_display(&project_dir),
2798 },
2799 config: InitPathStatus {
2800 status: init_path_status(config_existed),
2801 path: lossless_native_path_display(&config_file),
2802 },
2803 nonsource_files: InitPathStatus {
2804 status: init_path_status(nonsource_existed),
2805 path: lossless_native_path_display(&nonsource_file),
2806 },
2807 db: InitPathStatus {
2808 status: init_path_status(db_existed),
2809 path: lossless_native_path_display(db_path),
2810 },
2811 host_configs: Vec::new(),
2812 scan: InitScanPhase {
2813 status: scan_status,
2814 requested: !options.no_scan,
2815 force_rescan: options.force_rescan,
2816 report: scan_report,
2817 error: scan_error,
2818 },
2819 hydration: None,
2820 purpose_handoff: purpose_curator_handoff(purpose_queue),
2821 next_steps,
2822 })
2823}
2824
2825pub(crate) fn preflight_existing_project_binding(
2832 db_path: &Path,
2833 root: &Path,
2834) -> Result<(), CliError> {
2835 let admission = preflight_managed_database(db_path, root)?;
2836 let db_path = admitted_database_path(db_path, admission.as_ref());
2837 preflight_project_binding_read_only(db_path, root).map_err(project_store_error)
2838}
2839
2840pub(crate) fn init_path_status(existed: bool) -> InitPhaseStatus {
2842 if existed {
2843 InitPhaseStatus::Exists
2844 } else {
2845 InitPhaseStatus::Created
2846 }
2847}
2848
2849fn purpose_handoff_instructions() -> Vec<String> {
2851 vec![
2852 "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(),
2853 "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(),
2854 "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(),
2855 "Keep successful curator maintenance out of normal conversation; ProjectAtlas reports a handoff and never claims that the Rust server spawned an agent.".to_string(),
2856 ]
2857}
2858
2859pub(crate) fn purpose_curator_handoff(queue: PurposeCurationPage) -> PurposeCuratorHandoff {
2861 PurposeCuratorHandoff {
2862 agent_harness_expected: true,
2863 execution_owner: "agent_host",
2864 recommended_subagent_reasoning: PURPOSE_CURATOR_RECOMMENDED_REASONING,
2865 main_agent_fallback: true,
2866 server_started_curator: false,
2867 silent_on_success: true,
2868 queue,
2869 instructions: purpose_handoff_instructions(),
2870 }
2871}
2872
2873pub(crate) fn init_next_steps(
2875 scan_skipped: bool,
2876 scan_failed: bool,
2877 purpose_queue_total: usize,
2878) -> Vec<String> {
2879 let mut steps = Vec::new();
2880 if scan_skipped {
2881 steps.push("Run projectatlas scan when you are ready to build the deep index.".to_string());
2882 } else if scan_failed {
2883 steps.push(
2884 "Fix the scan/index error and rerun projectatlas init or projectatlas scan."
2885 .to_string(),
2886 );
2887 }
2888 if purpose_queue_total > 0 {
2889 steps.push(
2890 "Use the purpose_handoff queue to delegate purpose creation/correction at the lowest reliable reasoning and cost tier the host supports."
2891 .to_string(),
2892 );
2893 } else {
2894 steps.push("Purpose queue is empty for the default high-impact scope.".to_string());
2895 }
2896 steps.push("Run projectatlas overview to confirm repository orientation.".to_string());
2897 steps
2898}
2899
2900pub(crate) fn ensure_parent_dir(path: &Path) -> Result<(), CliError> {
2902 let Some(parent) = path.parent() else {
2903 return Ok(());
2904 };
2905 if parent.as_os_str().is_empty() {
2906 return Ok(());
2907 }
2908 fs::create_dir_all(parent).map_err(|source| CliError::Io {
2909 path: parent.to_path_buf(),
2910 source,
2911 })
2912}
2913
2914pub(crate) fn config_root_mismatch_error(
2916 config_path: &Path,
2917 config_root: &Path,
2918 selected_root: &Path,
2919) -> CliError {
2920 CliError::InvalidInput(format!(
2921 "ProjectAtlas config '{}' resolves project root '{}' outside selected project root '{}'",
2922 config_path.display(),
2923 config_root.display(),
2924 selected_root.display()
2925 ))
2926}
2927
2928fn legacy_project_root_candidate(
2934 db: &Path,
2935 _admission: Option<&crate::project_state::ProjectStateGuard>,
2936) -> Result<Option<PathBuf>, CliError> {
2937 if !db.exists() {
2938 return Ok(None);
2939 }
2940 let Some(project_root) = read_legacy_project_root_candidate_read_only(db)? else {
2941 return Ok(None);
2942 };
2943 Ok(Some(canonical_source_project_root(Path::new(
2944 &project_root,
2945 ))?))
2946}
2947
2948pub(crate) fn default_mcp_project_root(
2950 db: &Path,
2951 config_path: Option<&Path>,
2952) -> Result<PathBuf, CliError> {
2953 resolve_database_project_root(db, config_path, true)
2954}
2955
2956pub(crate) fn preflight_database_discovery(
2961 db: &Path,
2962 config_path: Option<&Path>,
2963 isolated_binding: bool,
2964) -> Result<Option<crate::project_state::ProjectStateGuard>, CliError> {
2965 let root = selected_database_admission_root(db, config_path, isolated_binding)?;
2966 preflight_managed_database(db, &root)
2967}
2968
2969pub(crate) fn selected_database_admission_root(
2971 db: &Path,
2972 config_path: Option<&Path>,
2973 isolated_binding: bool,
2974) -> Result<PathBuf, CliError> {
2975 let root = if let Some(config_path) = config_path {
2976 load_atlas_config(Some(config_path))?.root
2977 } else if !isolated_binding && let Some(root) = project_root_from_db_path(db) {
2978 root
2979 } else {
2980 std::env::current_dir().map_err(|source| CliError::Io {
2981 path: PathBuf::from("."),
2982 source,
2983 })?
2984 };
2985 Ok(root)
2986}
2987
2988fn resolve_database_project_root(
2990 db: &Path,
2991 config_path: Option<&Path>,
2992 isolated_binding: bool,
2993) -> Result<PathBuf, CliError> {
2994 let admission = preflight_database_discovery(db, config_path, isolated_binding)?;
2995 let db = admitted_database_path(db, admission.as_ref());
2996 let legacy_root = legacy_project_root_candidate(db, admission.as_ref())?;
2997 if let Some(config_path) = config_path {
2998 let config = load_atlas_config(Some(config_path))?;
2999 let config_root = canonical_source_project_root(&config.root)?;
3000 if let Some(db_root) = project_root_from_db_path(db) {
3001 let db_root = canonical_source_project_root(&db_root)?;
3002 if config_root != db_root {
3003 return Err(config_root_mismatch_error(
3004 config_path,
3005 &config_root,
3006 &db_root,
3007 ));
3008 }
3009 }
3010 return Ok(config_root);
3011 }
3012 if db.exists()
3013 && let Some(project_root) = read_project_root_identity_read_only(db)?
3014 {
3015 return canonical_source_project_root(project_root.as_path());
3016 }
3017 if let Some(project_root) = legacy_root {
3018 return Ok(project_root);
3019 }
3020 if let Some(project_root) = project_root_from_db_path(db) {
3021 return canonical_source_project_root(&project_root);
3022 }
3023 let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
3024 path: PathBuf::from("."),
3025 source,
3026 })?;
3027 canonical_source_project_root(¤t_dir)
3028}
3029
3030pub(crate) fn default_cli_project_root(
3032 db: &Path,
3033 config_path: Option<&Path>,
3034 database_path_is_explicit: bool,
3035) -> Result<PathBuf, CliError> {
3036 if !database_path_is_explicit
3037 && config_path.is_none()
3038 && !db.exists()
3039 && let Some(project_root) = project_root_from_db_path(db)
3040 {
3041 return canonical_source_project_root(&project_root);
3042 }
3043 resolve_database_project_root(db, config_path, database_path_is_explicit)
3044}
3045
3046pub(crate) fn defaultable_cli_project_root(
3048 path: &Path,
3049 db: &Path,
3050 config_path: Option<&Path>,
3051 database_path_is_explicit: bool,
3052) -> Result<PathBuf, CliError> {
3053 if path == Path::new(".") {
3054 return default_cli_project_root(db, config_path, database_path_is_explicit);
3055 }
3056 Ok(path.to_path_buf())
3057}
3058
3059fn project_root_from_db_path(db: &Path) -> Option<PathBuf> {
3061 let parent = db.parent()?;
3062 let cache_dir_name = parent.file_name()?;
3063 if cache_dir_name != ".projectatlas" {
3064 return None;
3065 }
3066 parent
3067 .parent()
3068 .filter(|root| !root.as_os_str().is_empty())
3069 .map_or_else(|| Some(PathBuf::from(".")), |root| Some(root.to_path_buf()))
3070}
3071
3072pub(crate) fn scan_options_for_root(
3074 config_path: Option<&Path>,
3075 root: &Path,
3076) -> Result<ScanOptions, CliError> {
3077 Ok(load_scan_import_config(config_path, root)?
3078 .as_ref()
3079 .map_or_else(
3080 ScanOptions::default,
3081 atlas_map::AtlasMapConfig::scan_options,
3082 ))
3083}
3084
3085pub(crate) fn text_index_options(
3087 config: Option<&atlas_map::AtlasMapConfig>,
3088 max_bytes_override: Option<u64>,
3089) -> TextIndexOptions {
3090 let max_bytes = max_bytes_override
3091 .filter(|value| *value > 0)
3092 .or_else(|| config.map(atlas_map::AtlasMapConfig::text_index_max_bytes))
3093 .unwrap_or(atlas_map::DEFAULT_TEXT_INDEX_MAX_BYTES);
3094 TextIndexOptions::new(max_bytes)
3095}
3096
3097fn publication_base_generation(store: &AtlasStore) -> Result<IndexGeneration, CliError> {
3099 Ok(store
3100 .index_publication()?
3101 .map_or(IndexGeneration::ZERO, |publication| publication.generation))
3102}
3103
3104fn stage_full_index_publication(
3106 store: &AtlasStore,
3107 plan: &ScanRuntimePlan,
3108 symbol_options: &SymbolBuildOptions,
3109 reuse_unchanged_symbols: bool,
3110 import_legacy_purposes: bool,
3111 control: &IndexWorkControl,
3112) -> Result<IndexPublicationBatch, CliError> {
3113 stage_full_index_publication_from_source(
3114 store,
3115 plan,
3116 symbol_options,
3117 reuse_unchanged_symbols,
3118 import_legacy_purposes,
3119 None,
3120 control,
3121 )
3122}
3123
3124fn stage_full_index_publication_from_source(
3126 store: &AtlasStore,
3127 plan: &ScanRuntimePlan,
3128 symbol_options: &SymbolBuildOptions,
3129 reuse_unchanged_symbols: bool,
3130 import_legacy_purposes: bool,
3131 captured: Option<CapturedSourceState>,
3132 control: &IndexWorkControl,
3133) -> Result<IndexPublicationBatch, CliError> {
3134 if let Some(source) = &captured {
3135 verify_captured_source_identity(store, plan, source)?;
3136 }
3137 let base_generation = publication_base_generation(store)?;
3138 if let Some(source) = &captured
3139 && source.generation != base_generation
3140 {
3141 return Err(projectatlas_db::DbError::PublicationBaseGenerationChanged {
3142 expected: source.generation,
3143 found: base_generation,
3144 }
3145 .into());
3146 }
3147 let contract_fingerprint = plan.publication_contract_fingerprint();
3148 let previous_hashes = reuse_unchanged_symbols
3149 .then(|| indexed_file_hashes(store, control))
3150 .transpose()?;
3151 let nodes = match captured {
3152 Some(source) => source.nodes,
3153 None => scan_repo_controlled(
3154 &plan.root,
3155 &plan.scan_options,
3156 ScanLimits::default(),
3157 control,
3158 )
3159 .map_err(|source| source_inspection_error(&plan.root, source))?,
3160 };
3161 control.check(IndexWorkStage::Publication)?;
3162 let purpose_import = import_legacy_purposes
3163 .then(|| plan.purpose_import_snapshot_controlled(&nodes, control))
3164 .transpose()?;
3165 let protected_purpose_paths = protected_purpose_paths(&nodes, purpose_import.as_ref());
3166 let text_paths = nodes
3167 .iter()
3168 .filter(|node| node.kind == NodeKind::File)
3169 .map(|node| node.path.clone())
3170 .collect::<Vec<_>>();
3171 let text = stage_text_index_for_changed_paths_controlled(
3172 &plan.root,
3173 &nodes,
3174 plan.text_options,
3175 control,
3176 )?;
3177 let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
3178 let retained_before_symbols =
3179 staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
3180 .saturating_add(staged_string_bytes(&text_paths))
3181 .saturating_add(staged_node_bytes(&nodes))
3182 .saturating_add(staged_text_bytes(&text))
3183 .saturating_add(staged_file_content_classification_bytes(
3184 &content_classifications,
3185 ))
3186 .saturating_add(staged_purpose_bytes(purpose_import.as_ref()));
3187 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
3188 let mut symbols = stage_symbols_for_nodes_with_limits(
3189 store,
3190 &plan.root,
3191 #[cfg(feature = "optional-parser-supervisor")]
3192 &plan.optional_parser_selection,
3193 &nodes,
3194 symbol_options,
3195 previous_hashes.as_ref(),
3196 None,
3197 &text.rows,
3198 &protected_purpose_paths,
3199 control,
3200 symbol_limits,
3201 )?;
3202 let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
3203 .map_err(|source| source_inspection_error(&plan.root, source))?;
3204 let graph = graph_projection::stage_full_repository_graph_with_limit(
3205 store,
3206 &plan.root,
3207 base_generation,
3208 &nodes,
3209 &scan_policy,
3210 &symbols,
3211 control,
3212 MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_before_symbols),
3213 )?;
3214 graph.account_source_recovery(&mut symbols.report)?;
3215 let structural_summaries = stage_structural_summaries_for_nodes_controlled(
3216 store,
3217 &nodes,
3218 &text.rows,
3219 Some(&symbols),
3220 &protected_purpose_paths,
3221 symbol_options.effective_workers(),
3222 control,
3223 )?;
3224 enforce_publication_staging_budget(
3225 retained_before_symbols
3226 .saturating_add(symbols.retained_bytes)
3227 .saturating_add(graph.retained_bytes())
3228 .saturating_add(structural_summaries.retained_bytes),
3229 )?;
3230 Ok(IndexPublicationBatch {
3231 base_generation,
3232 contract_fingerprint,
3233 root: plan.root.clone(),
3234 nodes: NodePublicationBatch::Full { nodes },
3235 purpose_import,
3236 text_paths,
3237 text,
3238 content_classifications,
3239 symbols,
3240 graph,
3241 structural_summaries,
3242 })
3243}
3244
3245fn protected_purpose_paths(
3247 nodes: &[Node],
3248 purpose_import: Option<&PurposeImportSnapshot>,
3249) -> HashSet<String> {
3250 let indexed_paths = nodes
3251 .iter()
3252 .map(|node| node.path.as_str())
3253 .collect::<HashSet<_>>();
3254 let mut protected = BUILTIN_PROJECTATLAS_PURPOSES
3255 .iter()
3256 .filter(|(path, _purpose)| indexed_paths.contains(*path))
3257 .map(|(path, _purpose)| (*path).to_string())
3258 .collect::<HashSet<_>>();
3259 if let Some(snapshot) = purpose_import {
3260 protected.extend(
3261 snapshot
3262 .records
3263 .iter()
3264 .filter(|record| indexed_paths.contains(record.path.as_str()))
3265 .map(|record| record.path.clone()),
3266 );
3267 }
3268 protected
3269}
3270
3271fn staged_node_bytes(nodes: &[Node]) -> u64 {
3273 nodes.iter().fold(0_u64, |bytes, node| {
3274 bytes
3275 .saturating_add(node.path.len() as u64)
3276 .saturating_add(
3277 node.parent_path
3278 .as_ref()
3279 .map_or(0, |value| value.len() as u64),
3280 )
3281 .saturating_add(
3282 node.extension
3283 .as_ref()
3284 .map_or(0, |value| value.len() as u64),
3285 )
3286 .saturating_add(node.language.as_ref().map_or(0, |value| value.len() as u64))
3287 .saturating_add(
3288 node.content_hash
3289 .as_ref()
3290 .map_or(0, |value| value.len() as u64),
3291 )
3292 })
3293}
3294
3295fn staged_text_bytes(text: &TextIndexRefresh) -> u64 {
3297 text.rows.iter().fold(0_u64, |bytes, row| {
3298 let bytes = bytes.saturating_add(row.path.len() as u64);
3299 row.text.as_ref().map_or(bytes, |text| {
3300 bytes
3301 .saturating_add(text.path.len() as u64)
3302 .saturating_add(
3303 text.content_hash
3304 .as_ref()
3305 .map_or(0, |value| value.len() as u64),
3306 )
3307 .saturating_add(text.content.len() as u64)
3308 })
3309 })
3310}
3311
3312fn stage_file_content_classifications(
3314 nodes: &[Node],
3315 text_rows: &[TextIndexRow],
3316) -> Vec<FileContentClassification> {
3317 let languages = nodes
3318 .iter()
3319 .filter(|node| node.kind == NodeKind::File)
3320 .map(|node| (node.path.as_str(), node.language.as_deref()))
3321 .collect::<HashMap<_, _>>();
3322 text_rows
3323 .iter()
3324 .map(|row| FileContentClassification {
3325 path: row.path.clone(),
3326 classification: content_classification(
3327 languages.get(row.path.as_str()).copied().flatten(),
3328 row.reason == TextIndexSkipReason::Indexed,
3329 ),
3330 })
3331 .collect()
3332}
3333
3334fn staged_file_content_classification_bytes(rows: &[FileContentClassification]) -> u64 {
3336 rows.iter().fold(0_u64, |bytes, row| {
3337 bytes
3338 .saturating_add(row.path.len() as u64)
3339 .saturating_add(row.classification.as_str().len() as u64)
3340 })
3341}
3342
3343fn staged_purpose_bytes(purpose_import: Option<&PurposeImportSnapshot>) -> u64 {
3345 purpose_import.map_or(0, |snapshot| {
3346 snapshot
3347 .records
3348 .iter()
3349 .fold(snapshot.fingerprint.len() as u64, |bytes, record| {
3350 bytes
3351 .saturating_add(record.path.len() as u64)
3352 .saturating_add(record.summary.len() as u64)
3353 })
3354 })
3355}
3356
3357fn staged_string_bytes(values: &[String]) -> u64 {
3359 values.iter().fold(0_u64, |bytes, value| {
3360 bytes.saturating_add(value.len() as u64)
3361 })
3362}
3363
3364fn staged_publication_identity_bytes(root: &Path, contract_fingerprint: &str) -> u64 {
3366 (root.as_os_str().as_encoded_bytes().len() as u64)
3367 .saturating_add(contract_fingerprint.len() as u64)
3368}
3369
3370fn symbol_limits_with_remaining_staging_bytes(
3372 retained_bytes: u64,
3373) -> Result<SymbolPublicationLimits, CliError> {
3374 enforce_publication_staging_budget(retained_bytes)?;
3375 Ok(SymbolPublicationLimits {
3376 output_bytes: SymbolPublicationLimits::STANDARD
3377 .output_bytes
3378 .min(MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_bytes)),
3379 ..SymbolPublicationLimits::STANDARD
3380 })
3381}
3382
3383fn enforce_publication_staging_budget(retained_bytes: u64) -> Result<(), CliError> {
3385 if retained_bytes > MAX_PUBLICATION_STAGING_BYTES {
3386 return Err(IndexWorkFailure::resource_limit(
3387 IndexWorkStage::Publication,
3388 IndexWorkResource::OutputBytes,
3389 MAX_PUBLICATION_STAGING_BYTES,
3390 retained_bytes,
3391 )
3392 .into());
3393 }
3394 Ok(())
3395}
3396
3397fn expected_nodes_after_incremental(
3399 baseline_nodes: Vec<Node>,
3400 changed_nodes: &[Node],
3401 absent_paths: &[String],
3402) -> Vec<Node> {
3403 let absent_paths = absent_paths
3404 .iter()
3405 .map(String::as_str)
3406 .filter(|path| !matches!(*path, "" | "."))
3407 .collect::<HashSet<_>>();
3408 let mut expected = baseline_nodes
3409 .into_iter()
3410 .filter(|node| !repository_path_is_absent(&node.path, &absent_paths))
3411 .map(|node| (node.path.clone(), node))
3412 .collect::<BTreeMap<_, _>>();
3413 for node in changed_nodes {
3414 expected.insert(node.path.clone(), node.clone());
3415 }
3416 expected.into_values().collect()
3417}
3418
3419fn repository_path_is_absent(path: &str, absent_paths: &HashSet<&str>) -> bool {
3421 absent_paths.contains(path)
3422 || path
3423 .match_indices('/')
3424 .any(|(separator, _)| absent_paths.contains(&path[..separator]))
3425}
3426
3427fn publish_index_batch(
3429 store: &mut AtlasStore,
3430 batch: IndexPublicationBatch,
3431 control: &IndexWorkControl,
3432) -> Result<IndexPublicationOutcome, CliError> {
3433 control.check(IndexWorkStage::Publication)?;
3434 let IndexPublicationBatch {
3435 base_generation,
3436 contract_fingerprint,
3437 root,
3438 nodes,
3439 purpose_import,
3440 text_paths,
3441 text,
3442 content_classifications,
3443 mut symbols,
3444 graph,
3445 structural_summaries,
3446 } = batch;
3447 graph.revalidate_document_targets(&root)?;
3448 let mut publication =
3449 store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
3450 publication.set_project_root(&root)?;
3451 let indexed_nodes = match nodes {
3452 NodePublicationBatch::Full { nodes } => {
3453 publication.begin_scan_replacement()?;
3454 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3455 control.check(IndexWorkStage::Publication)?;
3456 publication.upsert_scan_node_batch(batch)?;
3457 }
3458 apply_file_content_classification_stage(
3459 &mut publication,
3460 &content_classifications,
3461 control,
3462 )?;
3463 control.check(IndexWorkStage::Publication)?;
3464 publication.finish_scan_replacement()?;
3465 nodes
3466 }
3467 NodePublicationBatch::Incremental {
3468 nodes,
3469 absent_paths,
3470 expected_nodes: _,
3471 } => {
3472 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
3473 control.check(IndexWorkStage::Publication)?;
3474 publication.upsert_scan_node_batch(batch)?;
3475 }
3476 for batch in absent_paths.chunks(PUBLICATION_PATH_BATCH_SIZE) {
3477 control.check(IndexWorkStage::Publication)?;
3478 publication.mark_paths_absent(batch)?;
3479 }
3480 apply_file_content_classification_stage(
3481 &mut publication,
3482 &content_classifications,
3483 control,
3484 )?;
3485 nodes
3486 }
3487 };
3488 seed_builtin_projectatlas_purposes(&publication, &indexed_nodes)?;
3489 apply_text_index_stage(&mut publication, &text_paths, &text, control)?;
3490 let purpose_import = purpose_import.map_or_else(
3491 || Ok(PurposeImportReport::default()),
3492 |snapshot| apply_purpose_import_snapshot(&publication, &indexed_nodes, &snapshot, control),
3493 )?;
3494 apply_symbol_build_stage(&mut publication, &mut symbols, control)?;
3495 graph.apply(&mut publication, control)?;
3496 apply_structural_summary_stage(&mut publication, &structural_summaries, control)?;
3497 complete_index_publication(publication, control)?;
3498 Ok(IndexPublicationOutcome {
3499 purpose_import,
3500 text_index: text.report,
3501 structural_summaries: structural_summaries.report,
3502 symbols: symbols.report,
3503 })
3504}
3505
3506fn apply_file_content_classification_stage(
3508 publication: &mut IndexPublicationGuard<'_>,
3509 rows: &[FileContentClassification],
3510 control: &IndexWorkControl,
3511) -> Result<(), CliError> {
3512 for batch in rows.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
3513 control.check(IndexWorkStage::Publication)?;
3514 publication.upsert_file_content_classification_batch(batch)?;
3515 }
3516 Ok(())
3517}
3518
3519fn apply_purpose_import_snapshot(
3521 store: &AtlasStore,
3522 nodes: &[Node],
3523 snapshot: &PurposeImportSnapshot,
3524 control: &IndexWorkControl,
3525) -> Result<PurposeImportReport, CliError> {
3526 let indexed_paths = nodes
3527 .iter()
3528 .map(|node| node.path.as_str())
3529 .collect::<HashSet<_>>();
3530 let mut report = PurposeImportReport::default();
3531 for record in &snapshot.records {
3532 control.check(IndexWorkStage::Publication)?;
3533 if !indexed_paths.contains(record.path.as_str()) {
3534 report.skipped_stale += 1;
3535 continue;
3536 }
3537 let Some(indexed) = store.load_node_by_path(&record.path)? else {
3538 report.skipped_stale += 1;
3539 continue;
3540 };
3541 if matches!(
3542 indexed.purpose.status,
3543 PurposeStatus::Approved | PurposeStatus::Stale
3544 ) {
3545 report.skipped_existing += 1;
3546 continue;
3547 }
3548 store.set_purpose(&record.path, &record.summary, PurposeSource::Imported)?;
3549 report.imported += 1;
3550 }
3551 Ok(report)
3552}
3553
3554#[cfg(test)]
3556pub(crate) fn run_scan_pipeline(
3557 store: &mut AtlasStore,
3558 plan: &ScanRuntimePlan,
3559 symbol_options: &SymbolBuildOptions,
3560) -> Result<ScanReport, CliError> {
3561 let control = index_work_control(symbol_options);
3562 run_scan_pipeline_controlled(store, plan, symbol_options, &control)
3563}
3564
3565pub(crate) fn run_scan_pipeline_controlled(
3567 store: &mut AtlasStore,
3568 plan: &ScanRuntimePlan,
3569 symbol_options: &SymbolBuildOptions,
3570 control: &IndexWorkControl,
3571) -> Result<ScanReport, CliError> {
3572 let bounded_control = bounded_index_work_control(control);
3573 let control = &bounded_control;
3574 control.check(IndexWorkStage::Publication)?;
3575 store.probe_index_publication_writer()?;
3576 let batch = stage_full_index_publication(store, plan, symbol_options, false, true, control)?;
3577 revalidate_staged_publication_inputs_with_purpose_snapshot(
3578 plan,
3579 batch.nodes.expected_nodes(),
3580 batch.purpose_import.as_ref(),
3581 control,
3582 )?;
3583 let outcome = publish_index_batch(store, batch, control)?;
3584 let overview = store.overview()?;
3585 Ok(ScanReport {
3586 overview,
3587 purpose_import: outcome.purpose_import,
3588 text_index: outcome.text_index,
3589 structural_summaries: outcome.structural_summaries,
3590 symbols: outcome.symbols,
3591 })
3592}
3593
3594pub(crate) fn reconcile_hydrated_index_controlled(
3596 store: &mut AtlasStore,
3597 plan: &ScanRuntimePlan,
3598 symbol_options: &SymbolBuildOptions,
3599 control: &IndexWorkControl,
3600) -> Result<(ScanReport, bool), CliError> {
3601 if !publication_contract_matches(store, plan)? {
3602 return run_scan_pipeline_controlled(store, plan, symbol_options, control)
3603 .map(|report| (report, false));
3604 }
3605 let assessment =
3606 detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?;
3607 let (refresh, source_unchanged) = match assessment.delta {
3608 None => (empty_index_refresh_report(plan.text_options), true),
3609 Some(delta) if delta.report.scope == IndexRefreshScope::Incremental => {
3610 let changes = WatchChangeSet {
3611 requires_full_scan: false,
3612 document_paths: delta.paths.clone(),
3613 paths: delta.paths,
3614 };
3615 let refresh = match refresh_index_for_changes_from_source_controlled(
3616 store,
3617 plan,
3618 &changes,
3619 symbol_options,
3620 Some(assessment.source),
3621 control,
3622 ) {
3623 Ok(refresh) => refresh,
3624 Err(CliError::RefreshRequired(report))
3625 if report.reason == IndexRefreshReason::DependencyClosureLimit =>
3626 {
3627 refresh_index_controlled(store, plan, symbol_options, control)?
3628 }
3629 Err(error) => return Err(error),
3630 };
3631 (refresh, false)
3632 }
3633 Some(_delta) => (
3634 refresh_index_controlled(store, plan, symbol_options, control)?,
3635 false,
3636 ),
3637 };
3638 Ok((
3639 ScanReport {
3640 overview: store.overview()?,
3641 purpose_import: PurposeImportReport::default(),
3642 text_index: refresh.text_index,
3643 structural_summaries: refresh.structural_summaries,
3644 symbols: refresh.symbols,
3645 },
3646 source_unchanged,
3647 ))
3648}
3649
3650#[cfg(test)]
3652pub(crate) fn run_symbol_build_pipeline(
3653 store: &mut AtlasStore,
3654 plan: &ScanRuntimePlan,
3655 symbol_options: &SymbolBuildOptions,
3656 previous_hashes: Option<&HashMap<String, String>>,
3657) -> Result<SymbolBuildReport, CliError> {
3658 let control = index_work_control(symbol_options);
3659 run_symbol_build_pipeline_controlled(store, plan, symbol_options, previous_hashes, &control)
3660}
3661
3662pub(crate) fn run_symbol_build_pipeline_controlled(
3664 store: &mut AtlasStore,
3665 plan: &ScanRuntimePlan,
3666 symbol_options: &SymbolBuildOptions,
3667 previous_hashes: Option<&HashMap<String, String>>,
3668 control: &IndexWorkControl,
3669) -> Result<SymbolBuildReport, CliError> {
3670 let bounded_control = bounded_index_work_control(control);
3671 let control = &bounded_control;
3672 control.check(IndexWorkStage::SymbolParsing)?;
3673 verify_index_project_root(store, &plan.root)?;
3674 verify_index_publication(store, plan)?;
3675 control.check(IndexWorkStage::Publication)?;
3676 store.probe_index_publication_writer()?;
3677 let base_generation = publication_base_generation(store)?;
3678 let mut nodes = Vec::new();
3679 store.visit_source_nodes_controlled(control, |node| {
3680 nodes.push(node);
3681 Ok(())
3682 })?;
3683 let contract_fingerprint = plan.publication_contract_fingerprint();
3684 let retained_before_symbols =
3685 staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
3686 .saturating_add(staged_node_bytes(&nodes));
3687 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
3688 let mut staged = stage_symbols_for_nodes_with_limits(
3689 store,
3690 &plan.root,
3691 #[cfg(feature = "optional-parser-supervisor")]
3692 &plan.optional_parser_selection,
3693 &nodes,
3694 symbol_options,
3695 previous_hashes,
3696 None,
3697 &[],
3698 &HashSet::new(),
3699 control,
3700 symbol_limits,
3701 )?;
3702 let scan_policy = RootScanPolicy::discover(&plan.root, &plan.scan_options, control)
3703 .map_err(|source| source_inspection_error(&plan.root, source))?;
3704 let graph = graph_projection::stage_full_repository_graph_with_limit(
3705 store,
3706 &plan.root,
3707 base_generation,
3708 &nodes,
3709 &scan_policy,
3710 &staged,
3711 control,
3712 MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_before_symbols),
3713 )?;
3714 graph.account_source_recovery(&mut staged.report)?;
3715 enforce_publication_staging_budget(
3716 retained_before_symbols
3717 .saturating_add(staged.retained_bytes)
3718 .saturating_add(graph.retained_bytes()),
3719 )?;
3720 revalidate_staged_publication_inputs_controlled(plan, &nodes, None, control)?;
3721 control.check(IndexWorkStage::Publication)?;
3722 let mut publication =
3723 store.begin_index_projection_refresh_from(&contract_fingerprint, base_generation)?;
3724 apply_symbol_build_stage(&mut publication, &mut staged, control)?;
3725 graph.apply(&mut publication, control)?;
3726 complete_index_publication(publication, control)?;
3727 Ok(staged.report)
3728}
3729
3730#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3732pub(crate) struct UsageRuntimeInstance {
3733 id: UsageInstanceId,
3735 owner: UsageInstanceOwner,
3737}
3738
3739impl UsageRuntimeInstance {
3740 #[must_use]
3742 pub(crate) fn new(owner: UsageInstanceOwner) -> Option<Self> {
3743 let mut bytes = [0u8; 16];
3744 getrandom::fill(&mut bytes).ok()?;
3745 UsageInstanceId::from_bytes(bytes)
3746 .ok()
3747 .map(|id| Self { id, owner })
3748 }
3749
3750 fn record(
3752 self,
3753 store: &AtlasStore,
3754 event: &projectatlas_core::telemetry::UsageEvent,
3755 ) -> Result<(), CliError> {
3756 store.record_usage_for_instance(
3757 self.id,
3758 self.owner,
3759 event,
3760 matches!(self.owner, UsageInstanceOwner::CliInvocation),
3761 )?;
3762 Ok(())
3763 }
3764
3765 pub(crate) fn record_for_worktree(
3767 self,
3768 store: &AtlasStore,
3769 registration_id: i64,
3770 event: &projectatlas_core::telemetry::UsageEvent,
3771 ) -> Result<(), CliError> {
3772 store.record_usage_for_worktree_instance(
3773 self.id,
3774 self.owner,
3775 registration_id,
3776 event,
3777 false,
3778 )?;
3779 Ok(())
3780 }
3781
3782 pub(crate) fn seal(self, store: &AtlasStore) -> Result<(), CliError> {
3784 store.seal_usage_instance(self.id)?;
3785 Ok(())
3786 }
3787}
3788
3789pub(crate) fn record_usage_estimate(
3791 store: &AtlasStore,
3792 usage_instance: Option<UsageRuntimeInstance>,
3793 session: &str,
3794 command: &str,
3795 path: Option<String>,
3796 query: Option<String>,
3797 estimated_without_projectatlas: usize,
3798 projectatlas_text: &str,
3799) -> Result<(), CliError> {
3800 record_usage_estimate_with_context(
3801 store,
3802 usage_instance,
3803 session,
3804 command,
3805 path,
3806 query,
3807 estimated_without_projectatlas,
3808 projectatlas_text,
3809 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3810 TOKEN_BASELINE_SELECTED_CANDIDATES,
3811 TOKEN_CONFIDENCE_INFERRED,
3812 )
3813}
3814
3815#[allow(clippy::too_many_arguments)]
3817pub(crate) fn record_usage_estimate_with_context(
3818 store: &AtlasStore,
3819 usage_instance: Option<UsageRuntimeInstance>,
3820 session: &str,
3821 command: &str,
3822 path: Option<String>,
3823 query: Option<String>,
3824 estimated_without_projectatlas: usize,
3825 projectatlas_text: &str,
3826 token_savings_bucket: &str,
3827 baseline_kind: &str,
3828 confidence: &str,
3829) -> Result<(), CliError> {
3830 let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3831 return Ok(());
3832 };
3833 store.finish_index_read_snapshot()?;
3834 usage_instance.record(
3835 store,
3836 &usage_from_estimates_with_context(
3837 session,
3838 command,
3839 path,
3840 query,
3841 estimated_without_projectatlas,
3842 estimate_tokens(projectatlas_text),
3843 token_savings_bucket,
3844 baseline_kind,
3845 confidence,
3846 ),
3847 )?;
3848 Ok(())
3849}
3850
3851pub(crate) fn record_directory_walk_usage_estimate(
3853 store: &AtlasStore,
3854 usage_instance: Option<UsageRuntimeInstance>,
3855 session: &str,
3856 command: &str,
3857 path: Option<String>,
3858 query: Option<String>,
3859 estimated_without_projectatlas: usize,
3860 projectatlas_text: &str,
3861) -> Result<(), CliError> {
3862 record_usage_estimate_with_context(
3863 store,
3864 usage_instance,
3865 session,
3866 command,
3867 path,
3868 query,
3869 estimated_without_projectatlas,
3870 projectatlas_text,
3871 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3872 TOKEN_BASELINE_DIRECTORY_WALK,
3873 TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3874 )
3875}
3876
3877pub(crate) fn record_usage_text(
3879 store: &AtlasStore,
3880 usage_instance: Option<UsageRuntimeInstance>,
3881 session: &str,
3882 command: &str,
3883 path: Option<String>,
3884 query: Option<String>,
3885 baseline_text: &str,
3886 projectatlas_text: &str,
3887) -> Result<(), CliError> {
3888 let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3889 return Ok(());
3890 };
3891 store.finish_index_read_snapshot()?;
3892 usage_instance.record(
3893 store,
3894 &usage_from_text(
3895 session,
3896 command,
3897 path,
3898 query,
3899 baseline_text,
3900 projectatlas_text,
3901 ),
3902 )?;
3903 Ok(())
3904}
3905
3906pub(crate) fn telemetry_disabled() -> bool {
3908 truthy_env("PROJECTATLAS_NO_TELEMETRY")
3909}
3910
3911pub(crate) fn estimated_source_tokens_for_indexed_files(
3913 store: &AtlasStore,
3914 folder: Option<&str>,
3915 file_pattern: Option<&str>,
3916) -> Result<usize, CliError> {
3917 let matcher = FilePathMatcher::new(file_pattern)?;
3918 let mut total = 0usize;
3919 store.visit_file_token_estimates(folder, |path, size_bytes| {
3920 if matcher.is_match(&path) {
3921 total =
3922 total.saturating_add(estimated_source_tokens_for_file_metadata(&path, size_bytes));
3923 }
3924 Ok(true)
3925 })?;
3926 Ok(total)
3927}
3928
3929pub(crate) fn estimated_source_tokens_for_file_node(node: &Node) -> usize {
3931 estimated_source_tokens_for_file_metadata(&node.path, node.size_bytes)
3932}
3933
3934pub(crate) fn estimated_source_tokens_for_file_metadata(
3936 path: &str,
3937 size_bytes: Option<u64>,
3938) -> usize {
3939 size_bytes.map_or_else(|| estimate_tokens(path), byte_size_to_tokens)
3940}
3941
3942pub(crate) fn byte_size_to_tokens(bytes: u64) -> usize {
3944 let token_estimate = bytes.div_ceil(4);
3945 usize::try_from(token_estimate).unwrap_or(usize::MAX)
3946}
3947
3948pub(crate) fn byte_count_to_tokens(bytes: usize) -> usize {
3950 if bytes == 0 { 0 } else { bytes.div_ceil(4) }
3951}
3952
3953pub(crate) fn ranked_folder_nodes_with_reasons(
3955 store: &AtlasStore,
3956 query: &str,
3957 limit: usize,
3958) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3959 Ok(load_ranked_folder_nodes_with_reasons(store, query, limit)?)
3960}
3961
3962pub(crate) fn ranked_file_nodes_with_reasons(
3964 store: &AtlasStore,
3965 query: &str,
3966 folder: Option<&str>,
3967 file_pattern: Option<&str>,
3968 limit: usize,
3969 include_content: bool,
3970) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3971 Ok(load_ranked_file_nodes_with_reasons(
3972 store,
3973 query,
3974 folder,
3975 file_pattern,
3976 limit,
3977 include_content,
3978 )?)
3979}
3980
3981pub(crate) fn classified_ranked_file_nodes_with_reasons(
3983 store: &AtlasStore,
3984 query: &str,
3985 folder: Option<&str>,
3986 file_pattern: Option<&str>,
3987 limit: usize,
3988 include_content: bool,
3989 content_selection: ContentSelection,
3990) -> Result<Vec<ClassifiedRankedNode>, CliError> {
3991 Ok(load_classified_ranked_file_nodes_with_reasons_service(
3992 store,
3993 query,
3994 folder,
3995 file_pattern,
3996 limit,
3997 include_content,
3998 content_selection,
3999 )?)
4000}
4001
4002pub(crate) fn next_step_report_with_selection(
4004 store: &AtlasStore,
4005 query: &str,
4006 limit: Option<usize>,
4007 content_selection: ContentSelection,
4008) -> Result<NextStepReport, CliError> {
4009 Ok(build_next_report_with_selection_service(
4010 store,
4011 query,
4012 limit,
4013 content_selection,
4014 )?)
4015}
4016
4017pub(crate) fn next_step_report_payload(report: &NextStepReport) -> Value {
4019 json!({
4020 "query": &report.query,
4021 "folders": render_ranked_node_rows("folders", &report.folders),
4022 "files": render_classified_ranked_file_rows(&report.files),
4023 "suggestions": &report.suggestions,
4024 })
4025}
4026
4027pub(crate) fn render_classified_ranked_file_rows(files: &[ClassifiedRankedNode]) -> Vec<Value> {
4029 files
4030 .iter()
4031 .map(|file| {
4032 let mut row = render_ranked_node_rows("files", std::slice::from_ref(&file.ranked))
4033 .into_iter()
4034 .next()
4035 .unwrap_or_else(|| json!({}));
4036 if let Some(object) = row.as_object_mut() {
4037 object.insert("classification".to_string(), json!(file.classification));
4038 }
4039 row
4040 })
4041 .collect()
4042}
4043
4044pub(crate) fn render_classified_symbol_rows(symbols: &[ClassifiedSymbol]) -> Vec<Value> {
4046 symbols
4047 .iter()
4048 .map(|classified| {
4049 let mut row = render_symbol_rows(std::slice::from_ref(&classified.symbol))
4050 .into_iter()
4051 .next()
4052 .unwrap_or_else(|| json!({}));
4053 if let Some(object) = row.as_object_mut() {
4054 object.insert(
4055 "classification".to_string(),
4056 json!(classified.classification),
4057 );
4058 }
4059 row
4060 })
4061 .collect()
4062}
4063
4064#[derive(Debug, Serialize)]
4066#[allow(
4067 clippy::struct_excessive_bools,
4068 reason = "serialized queue paging and scope fields are independent wire facts"
4069)]
4070pub(crate) struct PurposeCurationPage {
4071 pub(crate) project_instance_id: String,
4073 pub(crate) active_generation: u64,
4075 pub(crate) task: String,
4077 pub(crate) work_key: String,
4079 pub(crate) actionable: bool,
4081 pub(crate) curation_scope: &'static str,
4083 pub(crate) total: usize,
4085 pub(crate) unfiltered_total: usize,
4087 pub(crate) returned: usize,
4089 pub(crate) start_index: usize,
4091 pub(crate) limit: usize,
4093 pub(crate) max_limit: usize,
4095 pub(crate) next_start_index: Option<usize>,
4097 pub(crate) truncated: bool,
4099 pub(crate) source_only: bool,
4101 pub(crate) folder_scope: String,
4103 pub(crate) file_scope: String,
4105 pub(crate) category: String,
4107 pub(crate) severity: String,
4109 pub(crate) path_prefix: String,
4111 pub(crate) summary_only: bool,
4113 pub(crate) items: Vec<PurposeCurationItem>,
4115}
4116
4117#[derive(Debug, Serialize)]
4119pub(crate) struct PurposeCurationItem {
4120 pub(crate) work_key: String,
4122 pub(crate) state_token: String,
4124 pub(crate) severity: String,
4126 pub(crate) id: String,
4128 pub(crate) category: String,
4130 pub(crate) path: String,
4132 pub(crate) related_path: String,
4134 pub(crate) kind: String,
4136 pub(crate) language: String,
4138 pub(crate) classification: Option<ContentClassification>,
4140 pub(crate) purpose: String,
4142 pub(crate) purpose_status: String,
4144 pub(crate) purpose_source: String,
4146 pub(crate) purpose_agent_reviewed: bool,
4148 pub(crate) review_priority: String,
4150 pub(crate) review_reason: String,
4152 pub(crate) content_summary: String,
4154 pub(crate) recommendation: String,
4156}
4157
4158#[derive(Clone, Debug, Deserialize, Serialize)]
4160pub(crate) struct PurposeReviewRequest {
4161 pub(crate) path: String,
4163 #[serde(default)]
4165 pub(crate) purpose: Option<String>,
4166 #[serde(default)]
4168 pub(crate) confirm_existing: bool,
4169 #[serde(default)]
4171 pub(crate) task: Option<String>,
4172 #[serde(default)]
4174 pub(crate) work_key: Option<String>,
4175 #[serde(default)]
4177 pub(crate) state_token: Option<String>,
4178}
4179
4180#[derive(Debug, Serialize)]
4182pub(crate) struct PurposeReviewReport {
4183 pub(crate) applied: bool,
4185 pub(crate) total: usize,
4187 pub(crate) changed: usize,
4189 pub(crate) skipped: usize,
4191 pub(crate) conflicts: usize,
4193 pub(crate) failed: usize,
4195 pub(crate) items: Vec<PurposeReviewItem>,
4197}
4198
4199#[derive(Debug, Serialize)]
4201pub(crate) struct PurposeReviewItem {
4202 pub(crate) path: String,
4204 pub(crate) classification: Option<ContentClassification>,
4206 pub(crate) action: PurposeReviewAction,
4208 pub(crate) current_status: String,
4210 pub(crate) current_source: String,
4212 pub(crate) purpose: String,
4214 pub(crate) error: String,
4216}
4217
4218#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
4220#[serde(rename_all = "kebab-case")]
4221pub(crate) enum PurposeReviewAction {
4222 Error,
4224 Skip,
4226 Review,
4228 WouldReview,
4230 Stale,
4232 Accepted,
4234 Unavailable,
4236}
4237
4238pub(crate) fn review_purposes(
4240 store: &AtlasStore,
4241 requests: &[PurposeReviewRequest],
4242 apply: bool,
4243) -> Result<PurposeReviewReport, CliError> {
4244 validate_purpose_review_admission(requests)?;
4245 let has_conditional_fields = requests.iter().any(has_conditional_purpose_review_field);
4246 if apply
4247 && has_conditional_fields
4248 && !requests.iter().all(is_complete_conditional_purpose_review)
4249 {
4250 return Err(CliError::InvalidInput(
4251 "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"
4252 .to_string(),
4253 ));
4254 }
4255
4256 if apply && !has_conditional_fields {
4260 let mut preview = collect_purpose_reviews(store, requests, false)?;
4261 hydrate_purpose_review_classifications(store, &mut preview)?;
4262 validate_purpose_review_report(&preview)?;
4263 }
4264
4265 let mut report = if apply && has_conditional_fields {
4266 apply_conditional_purpose_reviews(store, requests)?
4267 } else {
4268 collect_purpose_reviews(store, requests, apply)?
4269 };
4270 hydrate_purpose_review_classifications(store, &mut report)?;
4271 validate_purpose_review_report(&report)?;
4272 Ok(report)
4273}
4274
4275fn hydrate_purpose_review_classifications(
4277 store: &AtlasStore,
4278 report: &mut PurposeReviewReport,
4279) -> Result<(), CliError> {
4280 let paths = report
4281 .items
4282 .iter()
4283 .map(|item| item.path.clone())
4284 .collect::<Vec<_>>();
4285 let file_paths = store
4286 .load_nodes_by_paths(&paths)?
4287 .into_iter()
4288 .filter(|node| node.node.kind == NodeKind::File)
4289 .map(|node| node.node.path)
4290 .collect::<Vec<_>>();
4291 let classifications = store
4292 .file_content_classifications_for_paths(&file_paths)?
4293 .into_iter()
4294 .map(|row| (row.path, row.classification))
4295 .collect::<HashMap<_, _>>();
4296 for item in &mut report.items {
4297 item.classification = classifications.get(&item.path).copied();
4298 }
4299 Ok(())
4300}
4301
4302pub(crate) fn validate_purpose_review_admission(
4304 requests: &[PurposeReviewRequest],
4305) -> Result<(), CliError> {
4306 if requests.is_empty() {
4307 return Err(CliError::InvalidInput(
4308 "purpose review input must contain at least one item".to_string(),
4309 ));
4310 }
4311 if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
4312 return Err(CliError::InvalidInput(format!(
4313 "purpose review input contains {} items; maximum is {}",
4314 requests.len(),
4315 MAX_PURPOSE_CURATION_BATCH_ROWS
4316 )));
4317 }
4318
4319 let mut aggregate_bytes = 0usize;
4320 for (index, request) in requests.iter().enumerate() {
4321 validate_purpose_review_field(index, "path", &request.path, MAX_PURPOSE_REVIEW_PATH_BYTES)?;
4322 aggregate_bytes = aggregate_bytes
4323 .checked_add(request.path.len())
4324 .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4325 for (name, value) in [
4326 ("purpose", request.purpose.as_deref()),
4327 ("task", request.task.as_deref()),
4328 ("work_key", request.work_key.as_deref()),
4329 ("state_token", request.state_token.as_deref()),
4330 ] {
4331 if let Some(value) = value {
4332 validate_purpose_review_field(index, name, value, MAX_PURPOSE_REVIEW_FIELD_BYTES)?;
4333 aggregate_bytes = aggregate_bytes
4334 .checked_add(value.len())
4335 .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
4336 }
4337 }
4338 if aggregate_bytes > MAX_PURPOSE_REVIEW_INPUT_BYTES {
4339 return Err(purpose_review_input_too_large(aggregate_bytes));
4340 }
4341 }
4342 Ok(())
4343}
4344
4345fn validate_purpose_review_field(
4347 index: usize,
4348 name: &str,
4349 value: &str,
4350 maximum: usize,
4351) -> Result<(), CliError> {
4352 if value.len() > maximum {
4353 return Err(CliError::InvalidInput(format!(
4354 "purpose review item {index} field {name} contains {} bytes; maximum is {maximum}",
4355 value.len()
4356 )));
4357 }
4358 Ok(())
4359}
4360
4361fn purpose_review_input_too_large(actual: usize) -> CliError {
4363 CliError::InvalidInput(format!(
4364 "purpose review input contains {actual} aggregate string bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_BYTES}"
4365 ))
4366}
4367
4368fn collect_purpose_reviews(
4370 store: &AtlasStore,
4371 requests: &[PurposeReviewRequest],
4372 apply: bool,
4373) -> Result<PurposeReviewReport, CliError> {
4374 let mut items = Vec::with_capacity(requests.len());
4375 let mut retained_bytes = 0usize;
4376 for request in requests {
4377 let item = review_purpose_request(store, request, apply)?;
4378 retained_bytes = retained_bytes
4379 .checked_add(purpose_review_item_bytes(&item)?)
4380 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4381 if retained_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4382 return Err(purpose_review_report_too_large(retained_bytes));
4383 }
4384 items.push(item);
4385 }
4386 Ok(summarize_purpose_review(requests.len(), apply, items))
4387}
4388
4389fn purpose_review_item_bytes(item: &PurposeReviewItem) -> Result<usize, CliError> {
4391 let mut total = 0usize;
4392 for (name, value, maximum) in [
4393 ("path", item.path.as_str(), MAX_PURPOSE_REVIEW_PATH_BYTES),
4394 (
4395 "current_status",
4396 item.current_status.as_str(),
4397 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4398 ),
4399 (
4400 "current_source",
4401 item.current_source.as_str(),
4402 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4403 ),
4404 (
4405 "purpose",
4406 item.purpose.as_str(),
4407 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4408 ),
4409 (
4410 PURPOSE_REVIEW_REPORT_ERROR_FIELD,
4411 item.error.as_str(),
4412 MAX_PURPOSE_REVIEW_FIELD_BYTES,
4413 ),
4414 ] {
4415 if value.len() > maximum {
4416 return Err(CliError::InvalidInput(format!(
4417 "purpose review report field {name} contains {} bytes; maximum is {maximum}",
4418 value.len()
4419 )));
4420 }
4421 total = total
4422 .checked_add(value.len())
4423 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4424 }
4425 total = total
4426 .checked_add(item.classification.map_or(0, |value| value.as_str().len()))
4427 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4428 Ok(total)
4429}
4430
4431fn validate_purpose_review_report(report: &PurposeReviewReport) -> Result<(), CliError> {
4433 let json_bytes = serde_json::to_string_pretty(report)?
4434 .len()
4435 .checked_add(1)
4436 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
4437 if json_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4438 return Err(purpose_review_report_too_large(json_bytes));
4439 }
4440 let toon_bytes = render_purpose_review_report(report).len();
4441 if toon_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
4442 return Err(purpose_review_report_too_large(toon_bytes));
4443 }
4444 Ok(())
4445}
4446
4447fn purpose_review_report_too_large(actual: usize) -> CliError {
4449 CliError::InvalidInput(format!(
4450 "purpose review report contains {actual} bytes; maximum is {MAX_PURPOSE_REVIEW_REPORT_BYTES}"
4451 ))
4452}
4453
4454fn apply_conditional_purpose_reviews(
4456 store: &AtlasStore,
4457 requests: &[PurposeReviewRequest],
4458) -> Result<PurposeReviewReport, CliError> {
4459 let prepared = requests
4460 .iter()
4461 .map(|request| {
4462 let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4463 CliError::InvalidInput(format!(
4464 "invalid purpose review path {:?}: {source}",
4465 request.path
4466 ))
4467 })?;
4468 let purpose = request
4469 .purpose
4470 .as_deref()
4471 .map(str::trim)
4472 .filter(|value| !value.is_empty())
4473 .ok_or_else(|| {
4474 CliError::InvalidInput(
4475 "conditional purpose review requires an explicit reviewed purpose"
4476 .to_string(),
4477 )
4478 })?
4479 .to_string();
4480 let task = request.task.clone().ok_or_else(|| {
4481 CliError::InvalidInput(
4482 "conditional purpose review requires task, work_key, and state_token together"
4483 .to_string(),
4484 )
4485 })?;
4486 let work_key = request.work_key.clone().ok_or_else(|| {
4487 CliError::InvalidInput(
4488 "conditional purpose review requires task, work_key, and state_token together"
4489 .to_string(),
4490 )
4491 })?;
4492 let state_token = request.state_token.clone().ok_or_else(|| {
4493 CliError::InvalidInput(
4494 "conditional purpose review requires task, work_key, and state_token together"
4495 .to_string(),
4496 )
4497 })?;
4498 Ok((
4499 path.clone(),
4500 purpose.clone(),
4501 PurposeConditionalApplyRequest {
4502 task,
4503 path,
4504 work_key,
4505 state_token,
4506 purpose,
4507 },
4508 ))
4509 })
4510 .collect::<Result<Vec<_>, CliError>>()?;
4511 let database_requests = prepared
4512 .iter()
4513 .map(|(_, _, request)| request.clone())
4514 .collect::<Vec<_>>();
4515 let results = store.conditionally_set_purposes(&database_requests)?;
4516 let items = prepared
4517 .into_iter()
4518 .zip(results)
4519 .map(|((path, purpose, _), result)| {
4520 debug_assert_eq!(path, result.path);
4521 PurposeReviewItem {
4522 path,
4523 classification: None,
4524 action: conditional_purpose_review_action(result.state, true),
4525 current_status: result
4526 .current_purpose
4527 .as_ref()
4528 .map(|purpose| purpose.status.to_string())
4529 .unwrap_or_default(),
4530 current_source: result
4531 .current_purpose
4532 .as_ref()
4533 .map(|purpose| purpose.source.to_string())
4534 .unwrap_or_default(),
4535 purpose,
4536 error: String::new(),
4537 }
4538 })
4539 .collect::<Vec<_>>();
4540 Ok(summarize_purpose_review(requests.len(), true, items))
4541}
4542
4543fn is_complete_conditional_purpose_review(request: &PurposeReviewRequest) -> bool {
4545 request.task.is_some()
4546 && request.work_key.is_some()
4547 && request.state_token.is_some()
4548 && request
4549 .purpose
4550 .as_deref()
4551 .is_some_and(|purpose| !purpose.trim().is_empty())
4552}
4553
4554fn has_conditional_purpose_review_field(request: &PurposeReviewRequest) -> bool {
4556 request.task.is_some() || request.work_key.is_some() || request.state_token.is_some()
4557}
4558
4559fn summarize_purpose_review(
4561 total: usize,
4562 applied: bool,
4563 items: Vec<PurposeReviewItem>,
4564) -> PurposeReviewReport {
4565 let changed = items
4566 .iter()
4567 .filter(|item| {
4568 matches!(
4569 item.action,
4570 PurposeReviewAction::Review | PurposeReviewAction::WouldReview
4571 )
4572 })
4573 .count();
4574 let skipped = items
4575 .iter()
4576 .filter(|item| {
4577 matches!(
4578 item.action,
4579 PurposeReviewAction::Skip | PurposeReviewAction::Accepted
4580 )
4581 })
4582 .count();
4583 let conflicts = items
4584 .iter()
4585 .filter(|item| {
4586 matches!(
4587 item.action,
4588 PurposeReviewAction::Stale
4589 | PurposeReviewAction::Accepted
4590 | PurposeReviewAction::Unavailable
4591 )
4592 })
4593 .count();
4594 let failed = items.iter().filter(|item| !item.error.is_empty()).count();
4595 PurposeReviewReport {
4596 applied,
4597 total,
4598 changed,
4599 skipped,
4600 conflicts,
4601 failed,
4602 items,
4603 }
4604}
4605
4606fn review_purpose_request(
4608 store: &AtlasStore,
4609 request: &PurposeReviewRequest,
4610 apply: bool,
4611) -> Result<PurposeReviewItem, CliError> {
4612 let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
4613 CliError::InvalidInput(format!(
4614 "invalid purpose review path {:?}: {source}",
4615 request.path
4616 ))
4617 })?;
4618 let conditional = match (
4619 request.task.as_deref(),
4620 request.work_key.as_deref(),
4621 request.state_token.as_deref(),
4622 ) {
4623 (Some(task), Some(work_key), Some(state_token)) => Some((task, work_key, state_token)),
4624 (None, None, None) => None,
4625 _ => {
4626 return Ok(PurposeReviewItem {
4627 path,
4628 classification: None,
4629 action: PurposeReviewAction::Error,
4630 current_status: String::new(),
4631 current_source: String::new(),
4632 purpose: request.purpose.clone().unwrap_or_default(),
4633 error:
4634 "conditional purpose review requires task, work_key, and state_token together"
4635 .to_string(),
4636 });
4637 }
4638 };
4639 if let Some((task, work_key, state_token)) = conditional {
4640 let reviewed_purpose = request
4641 .purpose
4642 .as_deref()
4643 .map(str::trim)
4644 .filter(|value| !value.is_empty());
4645 let Some(reviewed_purpose) = reviewed_purpose else {
4646 return Ok(PurposeReviewItem {
4647 path,
4648 classification: None,
4649 action: PurposeReviewAction::Error,
4650 current_status: String::new(),
4651 current_source: String::new(),
4652 purpose: String::new(),
4653 error: "conditional purpose review requires an explicit reviewed purpose"
4654 .to_string(),
4655 });
4656 };
4657 let state = if apply {
4658 store.conditionally_set_purpose(task, &path, work_key, state_token, reviewed_purpose)?
4659 } else {
4660 preview_conditional_purpose_review(store, task, &path, work_key, state_token)?
4661 };
4662 let current = store.load_node_by_path(&path)?;
4663 return Ok(PurposeReviewItem {
4664 path,
4665 classification: None,
4666 action: conditional_purpose_review_action(state, apply),
4667 current_status: current
4668 .as_ref()
4669 .map(|node| node.purpose.status.to_string())
4670 .unwrap_or_default(),
4671 current_source: current
4672 .as_ref()
4673 .map(|node| node.purpose.source.to_string())
4674 .unwrap_or_default(),
4675 purpose: reviewed_purpose.to_string(),
4676 error: String::new(),
4677 });
4678 }
4679 let Some(indexed) = store.load_node_by_path(&path)? else {
4680 return Ok(PurposeReviewItem {
4681 path,
4682 classification: None,
4683 action: PurposeReviewAction::Error,
4684 current_status: String::new(),
4685 current_source: String::new(),
4686 purpose: request.purpose.clone().unwrap_or_default(),
4687 error: "path is not indexed".to_string(),
4688 });
4689 };
4690 let current_status = indexed.purpose.status.to_string();
4691 let current_source = indexed.purpose.source.to_string();
4692 let current_purpose = indexed.purpose.purpose.clone().unwrap_or_default();
4693 let explicit_purpose = request
4694 .purpose
4695 .as_deref()
4696 .map(str::trim)
4697 .filter(|value| !value.is_empty());
4698 let Some(reviewed_purpose) = explicit_purpose.or_else(|| {
4699 request
4700 .confirm_existing
4701 .then_some(current_purpose.as_str())
4702 .filter(|value| !value.trim().is_empty())
4703 }) else {
4704 return Ok(PurposeReviewItem {
4705 path,
4706 classification: None,
4707 action: PurposeReviewAction::Error,
4708 current_status,
4709 current_source,
4710 purpose: String::new(),
4711 error: "provide a reviewed purpose or set confirm_existing=true".to_string(),
4712 });
4713 };
4714
4715 if request.confirm_existing
4716 && explicit_purpose.is_none()
4717 && (indexed.purpose.status == PurposeStatus::Suggested
4718 || indexed.purpose.source == PurposeSource::Generated)
4719 {
4720 return Ok(PurposeReviewItem {
4721 path,
4722 classification: None,
4723 action: PurposeReviewAction::Error,
4724 current_status,
4725 current_source,
4726 purpose: current_purpose,
4727 error: "generated suggestions require an explicit reviewed purpose".to_string(),
4728 });
4729 }
4730
4731 let reviewed_purpose = reviewed_purpose.trim().to_string();
4732 let action = if indexed.purpose.agent_reviewed() && current_purpose == reviewed_purpose {
4733 PurposeReviewAction::Skip
4734 } else if apply {
4735 store.set_purpose(&path, &reviewed_purpose, PurposeSource::Agent)?;
4736 PurposeReviewAction::Review
4737 } else {
4738 PurposeReviewAction::WouldReview
4739 };
4740 Ok(PurposeReviewItem {
4741 path,
4742 classification: None,
4743 action,
4744 current_status,
4745 current_source,
4746 purpose: reviewed_purpose,
4747 error: String::new(),
4748 })
4749}
4750
4751fn preview_conditional_purpose_review(
4753 store: &AtlasStore,
4754 task: &str,
4755 path: &str,
4756 work_key: &str,
4757 state_token: &str,
4758) -> Result<PurposeConditionalApplyState, CliError> {
4759 let batch = store.load_purpose_curation_batch(task, &[path.to_string()])?;
4760 if let Some(candidate) = batch.items.first() {
4761 return Ok(
4762 if candidate.work_key == work_key && candidate.state_token == state_token {
4763 PurposeConditionalApplyState::Applied
4764 } else {
4765 PurposeConditionalApplyState::Stale
4766 },
4767 );
4768 }
4769 Ok(store
4770 .load_node_by_path(path)?
4771 .map_or(PurposeConditionalApplyState::PathUnavailable, |_node| {
4772 PurposeConditionalApplyState::Accepted
4773 }))
4774}
4775
4776const fn conditional_purpose_review_action(
4778 state: PurposeConditionalApplyState,
4779 apply: bool,
4780) -> PurposeReviewAction {
4781 match state {
4782 PurposeConditionalApplyState::Applied if apply => PurposeReviewAction::Review,
4783 PurposeConditionalApplyState::Applied => PurposeReviewAction::WouldReview,
4784 PurposeConditionalApplyState::Stale => PurposeReviewAction::Stale,
4785 PurposeConditionalApplyState::Accepted => PurposeReviewAction::Accepted,
4786 PurposeConditionalApplyState::PathUnavailable => PurposeReviewAction::Unavailable,
4787 }
4788}
4789
4790pub(crate) fn purpose_curation_page(
4792 store: &AtlasStore,
4793 query: &HealthQuery,
4794 task: &str,
4795) -> Result<PurposeCurationPage, CliError> {
4796 let page = store.purpose_curation_findings_page_current(query)?;
4797 let paths = page
4798 .findings
4799 .iter()
4800 .map(|finding| finding.path.clone())
4801 .collect::<Vec<_>>();
4802 let batch = store.load_purpose_curation_batch(task, &paths)?;
4803 let file_paths = batch
4804 .items
4805 .iter()
4806 .filter(|candidate| candidate.node.node.kind == NodeKind::File)
4807 .map(|candidate| candidate.node.node.path.clone())
4808 .collect::<Vec<_>>();
4809 let classifications = store
4810 .file_content_classifications_for_paths(&file_paths)?
4811 .into_iter()
4812 .map(|row| (row.path, row.classification))
4813 .collect::<HashMap<_, _>>();
4814 let project_instance_id = batch.project_instance_id.to_string();
4815 let active_generation = batch.active_generation.get();
4816 let task = batch.task.clone();
4817 let work_key = batch.work_key.clone();
4818 let candidates = batch
4819 .items
4820 .into_iter()
4821 .map(|candidate| (candidate.node.node.path.clone(), candidate))
4822 .collect::<HashMap<_, _>>();
4823 let items = page
4824 .findings
4825 .iter()
4826 .filter_map(|finding| {
4827 let candidate = candidates.get(&finding.path)?;
4828 let node = &candidate.node;
4829 let review_signal = purpose_review_signal(&node.node, &node.purpose);
4830 Some(PurposeCurationItem {
4831 work_key: candidate.work_key.clone(),
4832 state_token: candidate.state_token.clone(),
4833 severity: health_severity_name(finding.severity).to_string(),
4834 id: finding.id.clone(),
4835 category: finding.category.clone(),
4836 path: finding.path.clone(),
4837 related_path: finding.related_path.clone().unwrap_or_default(),
4838 kind: node.node.kind.to_string(),
4839 language: node.node.language.clone().unwrap_or_default(),
4840 classification: classifications.get(&finding.path).copied(),
4841 purpose: node.purpose.purpose.clone().unwrap_or_default(),
4842 purpose_status: node.purpose.status.to_string(),
4843 purpose_source: node.purpose.source.to_string(),
4844 purpose_agent_reviewed: node.purpose.agent_reviewed(),
4845 review_priority: review_signal.priority.to_string(),
4846 review_reason: review_signal.reason.to_string(),
4847 content_summary: node.summary.clone().unwrap_or_default(),
4848 recommendation: "Inspect bounded context, then use conditional purpose review with this task, work_key, and state_token."
4849 .to_string(),
4850 })
4851 })
4852 .collect::<Vec<_>>();
4853 let returned = items.len();
4854 Ok(PurposeCurationPage {
4855 project_instance_id,
4856 active_generation,
4857 task,
4858 work_key,
4859 actionable: returned > 0,
4860 curation_scope: purpose_queue_curation_scope(query),
4861 total: page.total,
4862 unfiltered_total: page.unfiltered_total,
4863 returned,
4864 start_index: page.start_index,
4865 limit: page.limit,
4866 max_limit: MAX_HEALTH_LIMIT,
4867 next_start_index: health_next_start_index(&page),
4868 truncated: health_next_start_index(&page).is_some(),
4869 source_only: query.scope.is_source_focused(),
4870 folder_scope: purpose_queue_folder_scope(query).to_string(),
4871 file_scope: purpose_queue_file_scope(query).to_string(),
4872 category: query.category.clone().unwrap_or_default(),
4873 severity: query.severity.map_or("", health_severity_name).to_string(),
4874 path_prefix: query.path_prefix.clone().unwrap_or_default(),
4875 summary_only: query.summary_only,
4876 items,
4877 })
4878}
4879
4880pub(crate) fn render_health_page(page: &HealthFindingsPage, query: &HealthQuery) -> String {
4882 let rows = page
4883 .findings
4884 .iter()
4885 .map(|finding| {
4886 json!({
4887 "severity": health_severity_name(finding.severity),
4888 "id": finding.id,
4889 "category": finding.category,
4890 "path": finding.path,
4891 "related_path": finding.related_path.as_deref().unwrap_or(""),
4892 "message": finding.message,
4893 "recommendation": finding.recommendation,
4894 })
4895 })
4896 .collect::<Vec<_>>();
4897 encode_agent_payload(&json!({
4898 "health": {
4899 "total": page.total,
4900 "unfiltered_total": page.unfiltered_total,
4901 "returned": page.returned,
4902 "start_index": page.start_index,
4903 "limit": page.limit,
4904 "max_limit": MAX_HEALTH_LIMIT,
4905 "next_start_index": health_next_start_index(page),
4906 "truncated": health_next_start_index(page).is_some(),
4907 "summary_only": query.summary_only,
4908 "source_only": query.scope.is_source_focused(),
4909 "category": query.category.as_deref().unwrap_or(""),
4910 "severity": query.severity.map_or("", health_severity_name),
4911 "path_prefix": query.path_prefix.as_deref().unwrap_or(""),
4912 },
4913 "health_findings": rows,
4914 }))
4915}
4916
4917pub(crate) fn render_coverage_report(report: &CoverageDiscoveryReport) -> String {
4919 encode_agent_payload(&json!({ "coverage": report }))
4920}
4921
4922pub(crate) fn render_purpose_curation_page(page: &PurposeCurationPage) -> String {
4924 encode_agent_payload(&json!({
4925 "purpose_curation": {
4926 "project_instance_id": page.project_instance_id,
4927 "active_generation": page.active_generation,
4928 "task": page.task,
4929 "work_key": page.work_key,
4930 "actionable": page.actionable,
4931 "curation_scope": page.curation_scope,
4932 "total": page.total,
4933 "unfiltered_total": page.unfiltered_total,
4934 "returned": page.returned,
4935 "start_index": page.start_index,
4936 "limit": page.limit,
4937 "max_limit": page.max_limit,
4938 "next_start_index": page.next_start_index,
4939 "truncated": page.truncated,
4940 "source_only": page.source_only,
4941 "folder_scope": page.folder_scope,
4942 "file_scope": page.file_scope,
4943 "category": page.category,
4944 "severity": page.severity,
4945 "path_prefix": page.path_prefix,
4946 "summary_only": page.summary_only,
4947 },
4948 "purpose_curation_items": page.items,
4949 }))
4950}
4951
4952pub(crate) fn render_purpose_review_report(report: &PurposeReviewReport) -> String {
4954 encode_agent_payload(&json!({
4955 "purpose_review": {
4956 "applied": report.applied,
4957 "total": report.total,
4958 "changed": report.changed,
4959 "skipped": report.skipped,
4960 "conflicts": report.conflicts,
4961 "failed": report.failed,
4962 },
4963 "purpose_review_items": report.items,
4964 }))
4965}
4966
4967fn purpose_queue_folder_scope(query: &HealthQuery) -> &'static str {
4969 match query.scope {
4970 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "source_relevant",
4971 _ => "all",
4972 }
4973}
4974
4975fn purpose_queue_file_scope(query: &HealthQuery) -> &'static str {
4977 match query.scope {
4978 HealthScope::PurposeDefault => "high_impact",
4979 HealthScope::PurposeWithAssets => "high_impact_and_assets",
4980 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "all_source",
4981 HealthScope::All | HealthScope::PurposeStrict => "all",
4982 }
4983}
4984
4985fn purpose_queue_curation_scope(query: &HealthQuery) -> &'static str {
4987 match query.scope {
4988 HealthScope::PurposeDefault => "low",
4989 HealthScope::PurposeWithAssets => "low_with_assets",
4990 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "medium",
4991 HealthScope::All | HealthScope::PurposeStrict => "strict",
4992 }
4993}
4994
4995pub(crate) fn health_severity_name(severity: Severity) -> &'static str {
4997 severity.as_str()
4998}
4999
5000fn health_next_start_index(page: &HealthFindingsPage) -> Option<usize> {
5002 let page_width = page.limit.min(page.total.saturating_sub(page.start_index));
5003 let page_end = page.start_index.saturating_add(page_width);
5004 if page_width == 0 || page_end >= page.total {
5005 None
5006 } else {
5007 Some(page_end)
5008 }
5009}
5010
5011pub(crate) fn estimated_source_tokens_for_paths<'a>(
5013 store: &AtlasStore,
5014 paths: impl Iterator<Item = &'a str>,
5015) -> Result<usize, CliError> {
5016 let mut seen = HashSet::new();
5017 let mut total = 0usize;
5018 for path in paths {
5019 if seen.insert(path.to_string()) {
5020 total = total.saturating_add(estimated_source_tokens_for_path(store, path)?);
5021 }
5022 }
5023 Ok(total)
5024}
5025
5026pub(crate) fn estimated_source_tokens_for_path(
5028 store: &AtlasStore,
5029 path: &str,
5030) -> Result<usize, CliError> {
5031 if let Some(indexed) = store.load_node_by_path(path)?
5032 && indexed.node.kind == NodeKind::File
5033 {
5034 return Ok(estimated_source_tokens_for_file_node(&indexed.node));
5035 }
5036 Ok(read_indexed_file_content(store, path).map_or_else(
5037 |_| estimate_tokens(path),
5038 |content| estimate_tokens(&content),
5039 ))
5040}
5041
5042#[derive(Clone, Debug, Serialize)]
5044pub(crate) struct TextIndexReport {
5045 pub(crate) candidates: usize,
5047 pub(crate) indexed: usize,
5049 pub(crate) binary_or_non_utf8: usize,
5051 pub(crate) too_large: usize,
5053 pub(crate) skipped: usize,
5055 pub(crate) max_bytes: u64,
5057 pub(crate) bytes: usize,
5059}
5060
5061#[derive(Clone, Debug, Default, Serialize)]
5063pub(crate) struct StructuralSummaryReport {
5064 pub(crate) candidates: usize,
5066 pub(crate) summarized: usize,
5068 pub(crate) cleared: usize,
5070 pub(crate) too_large: usize,
5072 pub(crate) binary_or_non_utf8: usize,
5074 pub(crate) purpose_suggestions: usize,
5076}
5077
5078#[derive(Clone, Copy, Debug)]
5080pub(crate) struct TextIndexOptions {
5081 pub(crate) max_bytes: u64,
5083}
5084
5085impl TextIndexOptions {
5086 pub(crate) fn new(max_bytes: u64) -> Self {
5088 Self { max_bytes }
5089 }
5090}
5091
5092#[derive(Clone, Debug)]
5094pub(crate) struct TextIndexRow {
5095 path: String,
5097 text: Option<IndexedFileText>,
5099 reason: TextIndexSkipReason,
5101}
5102
5103pub(crate) struct TextIndexRefresh {
5105 pub(crate) report: TextIndexReport,
5107 pub(crate) rows: Vec<TextIndexRow>,
5109}
5110
5111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5113pub(crate) enum TextIndexSkipReason {
5114 Indexed,
5116 TooLarge,
5118 BinaryOrNonUtf8,
5120}
5121
5122#[derive(Debug, Serialize)]
5124pub(crate) struct SymbolBuildReport {
5125 pub(crate) candidates: usize,
5127 pub(crate) parsed: usize,
5129 pub(crate) unchanged: usize,
5131 pub(crate) too_large: usize,
5133 pub(crate) binary_or_non_utf8: usize,
5135 pub(crate) timed_out: usize,
5137 pub(crate) max_workers: usize,
5139 pub(crate) timeout_seconds: Option<u64>,
5141 pub(crate) symbols: usize,
5143 pub(crate) relations: usize,
5145 pub(crate) summaries: usize,
5147 pub(crate) purpose_suggestions: usize,
5149}
5150
5151struct IndexPublicationBatch {
5153 base_generation: IndexGeneration,
5155 contract_fingerprint: String,
5157 root: PathBuf,
5159 nodes: NodePublicationBatch,
5161 purpose_import: Option<PurposeImportSnapshot>,
5163 text_paths: Vec<String>,
5165 text: TextIndexRefresh,
5167 content_classifications: Vec<FileContentClassification>,
5169 symbols: SymbolBuildStage,
5171 graph: graph_projection::StagedRepositoryGraph,
5173 structural_summaries: StructuralSummaryStage,
5175}
5176
5177enum NodePublicationBatch {
5179 Full {
5181 nodes: Vec<Node>,
5183 },
5184 Incremental {
5186 nodes: Vec<Node>,
5188 absent_paths: Vec<String>,
5190 expected_nodes: Vec<Node>,
5192 },
5193}
5194
5195impl NodePublicationBatch {
5196 fn expected_nodes(&self) -> &[Node] {
5198 match self {
5199 Self::Full { nodes } => nodes,
5200 Self::Incremental { expected_nodes, .. } => expected_nodes,
5201 }
5202 }
5203}
5204
5205struct IndexPublicationOutcome {
5207 purpose_import: PurposeImportReport,
5209 text_index: TextIndexReport,
5211 structural_summaries: StructuralSummaryReport,
5213 symbols: SymbolBuildReport,
5215}
5216
5217struct SymbolBuildStage {
5219 report: SymbolBuildReport,
5221 changes: Vec<SymbolProjectionChange>,
5223 retained_bytes: u64,
5225 identity_admission: graph_projection::GraphIdentityAdmission,
5227}
5228
5229enum SymbolProjectionChange {
5231 Parsed(SymbolParseSuccess),
5233 Clear {
5235 path: String,
5237 language: Option<String>,
5239 },
5240}
5241
5242struct StructuralSummaryStage {
5244 report: StructuralSummaryReport,
5246 changes: Vec<StructuralSummaryChange>,
5248 retained_bytes: u64,
5250}
5251
5252#[derive(Default)]
5254struct StructuralSummaryDerivation {
5255 change: Option<StructuralSummaryChange>,
5257 summarized: usize,
5259 cleared: usize,
5261 too_large: usize,
5263 binary_or_non_utf8: usize,
5265 purpose_suggestions: usize,
5267 retained_bytes: u64,
5269}
5270
5271enum StructuralSummaryChange {
5273 Set {
5275 path: String,
5277 summary: String,
5279 purpose_suggestion: Option<String>,
5281 },
5282 Clear {
5284 path: String,
5286 },
5287}
5288
5289#[derive(Debug, Serialize)]
5291pub(crate) struct WatchReport {
5292 pub(crate) mode: String,
5294 pub(crate) cycles: usize,
5296 pub(crate) once: bool,
5298 #[serde(skip_serializing_if = "Option::is_none")]
5300 pub(crate) fallback_reason: Option<String>,
5301 pub(crate) text_index: TextIndexReport,
5303 pub(crate) structural_summaries: StructuralSummaryReport,
5305 pub(crate) last_symbols: SymbolBuildReport,
5307}
5308
5309impl WatchReport {
5310 pub(crate) fn unchanged(text_options: TextIndexOptions) -> Self {
5312 Self::from_refresh(empty_index_refresh_report(text_options))
5313 }
5314
5315 pub(crate) fn from_refresh(refresh: IndexRefreshReport) -> Self {
5317 Self {
5318 mode: WATCH_MODE_ONCE.to_string(),
5319 cycles: 1,
5320 once: true,
5321 fallback_reason: None,
5322 text_index: refresh.text_index,
5323 structural_summaries: refresh.structural_summaries,
5324 last_symbols: refresh.symbols,
5325 }
5326 }
5327}
5328
5329#[derive(Debug, Default)]
5331pub(crate) struct WatchChangeSet {
5332 requires_full_scan: bool,
5334 paths: HashSet<PathBuf>,
5336 document_paths: HashSet<PathBuf>,
5339}
5340
5341impl WatchChangeSet {
5342 fn has_changes(&self) -> bool {
5344 self.requires_full_scan || !self.paths.is_empty() || !self.document_paths.is_empty()
5345 }
5346
5347 fn merge(&mut self, other: Self) {
5349 self.requires_full_scan |= other.requires_full_scan;
5350 self.paths.extend(other.paths);
5351 self.document_paths.extend(other.document_paths);
5352 }
5353}
5354
5355#[derive(Debug, Serialize)]
5357pub(crate) struct LegacyPurposeReport {
5358 pub(crate) applied: bool,
5360 pub(crate) purpose_files_found: usize,
5362 pub(crate) purpose_files_removed: usize,
5364 pub(crate) source_header_candidates: Vec<String>,
5366 pub(crate) purpose_files: Vec<String>,
5368}
5369
5370#[derive(Debug, Serialize)]
5372pub(crate) struct SettingsReport {
5373 pub(crate) cache_dir: PathStatus,
5375 pub(crate) db: PathStatus,
5377 pub(crate) db_wal: PathStatus,
5379 pub(crate) db_shm: PathStatus,
5381 pub(crate) db_journal: PathStatus,
5383 pub(crate) mcp_config: PathStatus,
5385 pub(crate) config_path: Option<String>,
5387 pub(crate) repo_root: Option<String>,
5389 pub(crate) root_detection_source: String,
5391 pub(crate) root_verified: bool,
5393 pub(crate) root_mismatches: Vec<String>,
5395 pub(crate) map_path: Option<String>,
5397 pub(crate) nonsource_files_path: Option<String>,
5399 pub(crate) default_format: String,
5401 pub(crate) default_search_case_sensitive: bool,
5403 pub(crate) search_source: String,
5405 pub(crate) text_index_max_bytes: u64,
5407 pub(crate) watcher: WatchStatusReport,
5409 pub(crate) index: Option<SettingsIndexStats>,
5411 pub(crate) telemetry: Option<TelemetryRetentionState>,
5413 pub(crate) database: DatabaseSettingsReport,
5415 pub(crate) language_registry: LanguageRegistryReport,
5417 pub(crate) classified_navigation: SettingsClassifiedNavigationReport,
5419 pub(crate) semantic_relation_contract_digest: String,
5421 pub(crate) relation_family_inventory: RelationFamilyInventoryReport,
5423 pub(crate) search: SettingsSearchReport,
5425 pub(crate) optional_parser_pack: OptionalParserSettingsReport,
5427}
5428
5429#[derive(Clone, Debug, Serialize)]
5431pub(crate) struct SettingsClassifiedNavigationReport {
5432 pub(crate) classifications: [ContentClassification; 5],
5434 pub(crate) selections: [ContentSelection; 3],
5436 pub(crate) document_relation: &'static str,
5438 pub(crate) inbound_document_view: &'static str,
5440}
5441
5442pub(crate) fn classified_navigation_capabilities() -> SettingsClassifiedNavigationReport {
5444 SettingsClassifiedNavigationReport {
5445 classifications: [
5446 ContentClassification::Source,
5447 ContentClassification::Documentation,
5448 ContentClassification::ConfigurationData,
5449 ContentClassification::OtherText,
5450 ContentClassification::Opaque,
5451 ],
5452 selections: [
5453 ContentSelection::Source,
5454 ContentSelection::Documentation,
5455 ContentSelection::Both,
5456 ],
5457 document_relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents).as_str(),
5458 inbound_document_view: "documented_by",
5459 }
5460}
5461
5462#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5464#[serde(rename_all = "snake_case")]
5465pub(crate) enum SettingsCapabilityState {
5466 Ready,
5468 Empty,
5470 Unavailable,
5472}
5473
5474#[derive(Debug, Serialize)]
5476pub(crate) struct SettingsSearchModeReport {
5477 pub(crate) state: SettingsCapabilityState,
5479}
5480
5481#[derive(Debug, Serialize)]
5483pub(crate) struct SettingsLexicalSearchReport {
5484 pub(crate) state: SettingsCapabilityState,
5486 pub(crate) source: &'static str,
5488 pub(crate) exact_verification: bool,
5490}
5491
5492#[derive(Debug, Serialize)]
5494pub(crate) struct SettingsSearchReport {
5495 pub(crate) default_mode: &'static str,
5497 pub(crate) lexical: SettingsLexicalSearchReport,
5499 pub(crate) fts: SettingsSearchModeReport,
5501 pub(crate) semantic: SettingsSearchModeReport,
5503 pub(crate) hybrid: SettingsSearchModeReport,
5505}
5506
5507#[derive(Debug, Serialize)]
5509pub(crate) struct OptionalParserSettingsReport {
5510 pub(crate) compiled: bool,
5512 #[cfg(feature = "optional-parser-supervisor")]
5514 pub(crate) lifecycle: OptionalParserPackLifecycleReport,
5515 #[cfg(not(feature = "optional-parser-supervisor"))]
5517 pub(crate) state: &'static str,
5518}
5519
5520#[derive(Debug, Serialize)]
5522pub(crate) struct PathStatus {
5523 pub(crate) path: Option<String>,
5525 pub(crate) exists: bool,
5527 pub(crate) size_bytes: Option<u64>,
5529}
5530
5531#[derive(Debug, Serialize)]
5533pub(crate) struct SettingsIndexStats {
5534 pub(crate) project_root: Option<String>,
5536 pub(crate) files: usize,
5538 pub(crate) folders: usize,
5540 pub(crate) missing_purposes: usize,
5542 pub(crate) stale_purposes: usize,
5544 pub(crate) suggested_purposes: usize,
5546 pub(crate) indexed_text_files: usize,
5548 pub(crate) indexed_text_bytes: usize,
5550 pub(crate) symbols: usize,
5552 pub(crate) relations: usize,
5554 pub(crate) token_calls: usize,
5556 pub(crate) health_findings: usize,
5558}
5559
5560#[derive(Debug, Serialize)]
5562pub(crate) struct WatchStatusReport {
5563 pub(crate) available: bool,
5565 pub(crate) active: bool,
5567 pub(crate) mode: String,
5569 pub(crate) event_backend_available: bool,
5571 pub(crate) recommendation: String,
5573}
5574
5575#[derive(Debug, Serialize)]
5577pub(crate) struct ResetIndexReport {
5578 pub(crate) applied: bool,
5580 pub(crate) dry_run: bool,
5582 files: Vec<PathStatus>,
5584 pub(crate) removed: usize,
5586}
5587
5588pub(crate) fn build_settings_report(
5590 db: &Path,
5591 config_path: Option<&Path>,
5592 format: OutputFormat,
5593) -> Result<SettingsReport, CliError> {
5594 let admission = preflight_database_discovery(db, config_path, true)?;
5595 let db = admitted_database_path(db, admission.as_ref());
5596 let absolute_db = absolute_path(db)?;
5597 let resolved_config = resolved_mcp_config_path(&absolute_db, config_path)?;
5598 let config = if let Some(config_path) = resolved_config.as_deref() {
5599 load_atlas_config(Some(config_path))?
5600 } else {
5601 let project_root = default_mcp_project_root(&absolute_db, None)?;
5602 load_atlas_config_for_root(&project_root)?
5603 };
5604 let config_root_identity = CanonicalProjectRoot::from_path(&config.root).ok();
5605 let cache_dir = absolute_db
5606 .parent()
5607 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
5608 let database = database_settings_report(&absolute_db)?;
5609 let (index, telemetry, file_text_fts, db_root_identity, db_root_matches_config) =
5610 if database.schema.compatibility == DatabaseSchemaCompatibility::Current {
5611 let store = AtlasStore::open_read_only(&absolute_db)?;
5612 let db_root_identity = store.project_root_identity()?;
5613 let db_root_matches_config = config_root_identity
5614 .as_ref()
5615 .is_some_and(|config_root| store.project_root_identity_matches(config_root));
5616 let snapshot_publication = store.index_publication()?;
5617 if settings_publication_matches(
5618 database.publication.as_ref(),
5619 snapshot_publication.as_ref(),
5620 ) {
5621 (
5622 Some(settings_index_stats(&store)?),
5623 Some(store.telemetry_retention_state()?),
5624 Some(store.file_text_fts_state()?),
5625 db_root_identity,
5626 db_root_matches_config,
5627 )
5628 } else {
5629 (None, None, None, db_root_identity, db_root_matches_config)
5630 }
5631 } else {
5632 (None, None, None, None, false)
5633 };
5634 let repo_root = config_root_identity
5635 .as_ref()
5636 .and_then(|root| root.display_string().ok());
5637 let mut root_mismatches = Vec::new();
5638 if let (Some(db_root), Some(config_root)) =
5639 (db_root_identity.as_ref(), config_root_identity.as_ref())
5640 {
5641 if !db_root_matches_config {
5642 root_mismatches.push(format!(
5643 "db root {:?} does not match config root {:?}",
5644 db_root
5645 .display_string()
5646 .unwrap_or_else(|_| "<native display unavailable>".to_string()),
5647 config_root
5648 .display_string()
5649 .unwrap_or_else(|_| "<native display unavailable>".to_string())
5650 ));
5651 }
5652 } else if db_root_identity.is_some() != config_root_identity.is_some() {
5653 root_mismatches
5654 .push("db root and config root do not both have a usable native identity".to_string());
5655 }
5656 let root_detection_source = if resolved_config.is_some() {
5657 "config"
5658 } else if db_root_identity.is_some() {
5659 "db"
5660 } else {
5661 "db-path-or-cwd"
5662 }
5663 .to_string();
5664 let lexical_publication_ready = database.publication.as_ref().is_some_and(|publication| {
5665 publication.state == IndexPublicationState::Complete
5666 && publication.generation != IndexGeneration::ZERO
5667 && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
5668 });
5669 let lexical_state = match (lexical_publication_ready, index.as_ref()) {
5670 (true, Some(stats)) if stats.indexed_text_files == 0 => SettingsCapabilityState::Empty,
5671 (true, Some(_)) => SettingsCapabilityState::Ready,
5672 _ => SettingsCapabilityState::Unavailable,
5673 };
5674 let fts_state = match (lexical_publication_ready, file_text_fts.as_ref()) {
5675 (true, Some(state)) if !state.synchronized => SettingsCapabilityState::Unavailable,
5676 (true, Some(state)) if state.source_rows == 0 => SettingsCapabilityState::Empty,
5677 (true, Some(_)) => SettingsCapabilityState::Ready,
5678 _ => SettingsCapabilityState::Unavailable,
5679 };
5680 let search = SettingsSearchReport {
5681 default_mode: "lexical",
5682 lexical: SettingsLexicalSearchReport {
5683 state: lexical_state,
5684 source: "persisted_text",
5685 exact_verification: true,
5686 },
5687 fts: SettingsSearchModeReport { state: fts_state },
5688 semantic: SettingsSearchModeReport {
5689 state: SettingsCapabilityState::Unavailable,
5690 },
5691 hybrid: SettingsSearchModeReport {
5692 state: SettingsCapabilityState::Unavailable,
5693 },
5694 };
5695 #[cfg(feature = "optional-parser-supervisor")]
5696 let optional_parser_pack = OptionalParserSettingsReport {
5697 compiled: true,
5698 lifecycle: OptionalParserPackLifecycle::new(&config.root, None)?.status()?,
5699 };
5700 #[cfg(not(feature = "optional-parser-supervisor"))]
5701 let optional_parser_pack = OptionalParserSettingsReport {
5702 compiled: false,
5703 state: "compiled_unavailable",
5704 };
5705 Ok(SettingsReport {
5706 cache_dir: path_status(&cache_dir)?,
5707 db: path_status(&absolute_db)?,
5708 db_wal: path_status(&db_sidecar_path(&absolute_db, "wal"))?,
5709 db_shm: path_status(&db_sidecar_path(&absolute_db, "shm"))?,
5710 db_journal: path_status(&db_sidecar_path(&absolute_db, "journal"))?,
5711 mcp_config: path_status(&mcp_config_path_for_db(&absolute_db))?,
5712 config_path: resolved_config
5713 .as_deref()
5714 .and_then(lossless_native_path_display),
5715 repo_root,
5716 root_detection_source,
5717 root_verified: root_mismatches.is_empty(),
5718 root_mismatches,
5719 map_path: lossless_native_path_display(&config.map_path),
5720 nonsource_files_path: lossless_native_path_display(&config.nonsource_files_path),
5721 default_format: format!("{format:?}").to_ascii_lowercase(),
5722 default_search_case_sensitive: false,
5723 search_source: "sqlite-file-text".to_string(),
5724 text_index_max_bytes: config.text_index_max_bytes(),
5725 watcher: watcher_status_report(false),
5726 index,
5727 telemetry,
5728 database,
5729 language_registry: language_registry_report(),
5730 classified_navigation: classified_navigation_capabilities(),
5731 semantic_relation_contract_digest: semantic_resolution_contract_digest(),
5732 relation_family_inventory: relation_family_inventory_report(),
5733 search,
5734 optional_parser_pack,
5735 })
5736}
5737
5738fn settings_publication_matches(
5740 diagnostic: Option<&DatabasePublicationReport>,
5741 snapshot: Option<&IndexPublication>,
5742) -> bool {
5743 match (diagnostic, snapshot) {
5744 (None, None) => true,
5745 (Some(diagnostic), Some(snapshot))
5746 if diagnostic.state == snapshot.state
5747 && diagnostic.generation == snapshot.generation =>
5748 {
5749 match diagnostic.contract_fingerprint_state {
5750 DatabasePublicationContractState::Missing => {
5751 snapshot.contract_fingerprint.is_none()
5752 }
5753 DatabasePublicationContractState::Valid => {
5754 diagnostic.contract_fingerprint.as_deref()
5755 == snapshot.contract_fingerprint.as_deref()
5756 }
5757 DatabasePublicationContractState::Invalid => false,
5758 }
5759 }
5760 _ => false,
5761 }
5762}
5763
5764pub(crate) fn settings_index_stats(store: &AtlasStore) -> Result<SettingsIndexStats, CliError> {
5766 let overview = store.overview()?;
5767 let health_findings = store.unresolved_health_finding_count_current()?;
5768 Ok(SettingsIndexStats {
5769 project_root: store
5770 .project_root_identity()?
5771 .and_then(|root| root.display_string().ok()),
5772 files: overview.files,
5773 folders: overview.folders,
5774 missing_purposes: overview.missing_purposes,
5775 stale_purposes: overview.stale_purposes,
5776 suggested_purposes: overview.suggested_purposes,
5777 indexed_text_files: store.file_text_count()?,
5778 indexed_text_bytes: store.file_text_byte_count()?,
5779 symbols: store.symbol_count()?,
5780 relations: store.symbol_relation_count()?,
5781 token_calls: store.token_overview(None)?.calls,
5782 health_findings,
5783 })
5784}
5785
5786pub(crate) fn reset_index_files(
5788 db: &Path,
5789 apply: bool,
5790 dry_run: bool,
5791 include_mcp_config: bool,
5792 admission: Option<&crate::project_state::ProjectStateGuard>,
5793) -> Result<ResetIndexReport, CliError> {
5794 let db = admitted_database_path(db, admission);
5795 let targets = reset_index_targets(db, include_mcp_config, admission)?;
5796 let files = targets
5797 .iter()
5798 .map(|path| path_status(path))
5799 .collect::<Result<Vec<_>, _>>()?;
5800 let should_apply = apply && !dry_run;
5801 let mut removed = 0;
5802 if should_apply {
5803 for target in &targets {
5804 verify_reset_admission(db, admission)?;
5805 if target.is_file() {
5806 fs::remove_file(target).map_err(|source| CliError::Io {
5807 path: target.clone(),
5808 source,
5809 })?;
5810 removed += 1;
5811 }
5812 }
5813 }
5814 Ok(ResetIndexReport {
5815 applied: should_apply,
5816 dry_run: !should_apply,
5817 files,
5818 removed,
5819 })
5820}
5821
5822pub(crate) fn reset_index_files_with_revalidation(
5824 db: &Path,
5825 include_mcp_config: bool,
5826 admission: Option<&crate::project_state::ProjectStateGuard>,
5827 mut revalidate: impl FnMut() -> Result<(), CliError>,
5828) -> Result<ResetIndexReport, CliError> {
5829 revalidate()?;
5832 let db = admitted_database_path(db, admission);
5833 let targets = reset_index_targets(db, include_mcp_config, admission)?;
5834 let files = targets
5835 .iter()
5836 .map(|path| path_status(path))
5837 .collect::<Result<Vec<_>, _>>()?;
5838 if !targets.iter().any(|target| target.is_file()) {
5839 revalidate()?;
5840 return Ok(ResetIndexReport {
5841 applied: true,
5842 dry_run: false,
5843 files,
5844 removed: 0,
5845 });
5846 }
5847
5848 let absolute_db = absolute_path(db)?;
5849 let parent = absolute_db.parent().ok_or_else(|| {
5850 CliError::InvalidInput("reset database path has no parent directory".to_string())
5851 })?;
5852 let recovery = tempfile::Builder::new()
5853 .prefix("reset-recovery-")
5854 .tempdir_in(parent)
5855 .map_err(|source| CliError::Io {
5856 path: parent.to_path_buf(),
5857 source,
5858 })?
5859 .keep();
5860 let mut staged = Vec::with_capacity(targets.len());
5861 for (index, target) in targets.iter().enumerate() {
5862 if let Err(error) = revalidate().and_then(|()| verify_reset_admission(db, admission)) {
5863 return Err(recover_staged_reset_files(
5864 db, admission, &recovery, &staged, error,
5865 ));
5866 }
5867 if !target.is_file() {
5868 continue;
5869 }
5870 let recovery_file = recovery.join(format!("{index:02}.reset"));
5871 if let Err(source) = move_reset_file_noclobber(target, &recovery_file) {
5872 let operation = revalidate().err().unwrap_or_else(|| CliError::Io {
5873 path: target.clone(),
5874 source,
5875 });
5876 return Err(recover_staged_reset_files(
5877 db, admission, &recovery, &staged, operation,
5878 ));
5879 }
5880 staged.push((target.clone(), recovery_file));
5881 }
5882
5883 if let Err(error) = revalidate().and_then(|()| verify_reset_admission(db, admission)) {
5884 return Err(recover_staged_reset_files(
5885 db, admission, &recovery, &staged, error,
5886 ));
5887 }
5888
5889 let removed = staged.len();
5890 for (_, recovery_file) in &staged {
5891 verify_reset_admission(db, admission).map_err(|error| {
5892 retained_reset_recovery(&recovery, &error, "owning namespace changed during cleanup")
5893 })?;
5894 if let Err(source) = fs::remove_file(recovery_file) {
5895 return Err(CliError::Io {
5896 path: recovery,
5897 source,
5898 });
5899 }
5900 }
5901 fs::remove_dir(&recovery).map_err(|source| CliError::Io {
5902 path: recovery,
5903 source,
5904 })?;
5905 Ok(ResetIndexReport {
5906 applied: true,
5907 dry_run: false,
5908 files,
5909 removed,
5910 })
5911}
5912
5913fn recover_staged_reset_files(
5915 db: &Path,
5916 admission: Option<&crate::project_state::ProjectStateGuard>,
5917 recovery: &Path,
5918 staged: &[(PathBuf, PathBuf)],
5919 operation: CliError,
5920) -> CliError {
5921 if let Err(error) = verify_reset_admission(db, admission) {
5922 return retained_reset_recovery(recovery, &operation, &error.to_string());
5923 }
5924 restore_staged_reset_files(db, admission, recovery, staged, operation)
5925}
5926
5927fn retained_reset_recovery(recovery: &Path, operation: &CliError, cause: &str) -> CliError {
5929 CliError::Io {
5930 path: recovery.to_path_buf(),
5931 source: io::Error::other(format!(
5932 "{operation}; staged reset recovery retained because its owning namespace could not be verified: {cause}; the reported recovery path is its original location and may have moved with that directory"
5933 )),
5934 }
5935}
5936
5937fn reset_index_targets(
5939 db: &Path,
5940 include_mcp_config: bool,
5941 admission: Option<&crate::project_state::ProjectStateGuard>,
5942) -> Result<Vec<PathBuf>, CliError> {
5943 let absolute_db = absolute_path(db)?;
5944 let mut targets = vec![
5945 absolute_db.clone(),
5946 db_sidecar_path(&absolute_db, "wal"),
5947 db_sidecar_path(&absolute_db, "shm"),
5948 db_sidecar_path(&absolute_db, "journal"),
5949 ];
5950 if let Some(admission) = admission {
5951 targets.extend(
5952 admission
5953 .alternate_reset_paths()
5954 .map_err(|source| CliError::Io {
5955 path: absolute_db.clone(),
5956 source,
5957 })?,
5958 );
5959 }
5960 if include_mcp_config {
5961 targets.push(mcp_config_path_for_db(&absolute_db));
5962 }
5963 targets.sort();
5964 targets.dedup();
5965 Ok(targets)
5966}
5967
5968fn verify_reset_admission(
5970 db: &Path,
5971 admission: Option<&crate::project_state::ProjectStateGuard>,
5972) -> Result<(), CliError> {
5973 if let Some(admission) = admission {
5974 admission.verify().map_err(|source| CliError::Io {
5975 path: db.to_path_buf(),
5976 source,
5977 })?;
5978 }
5979 Ok(())
5980}
5981
5982fn move_reset_file_noclobber(source: &Path, destination: &Path) -> io::Result<()> {
5984 let temporary = tempfile::TempPath::try_from_path(source)?;
5985 match temporary.persist_noclobber(destination) {
5986 Ok(()) => Ok(()),
5987 Err(mut error) => {
5988 error.path.disable_cleanup(true);
5989 Err(error.error)
5990 }
5991 }
5992}
5993
5994fn restore_staged_reset_files(
5996 db: &Path,
5997 admission: Option<&crate::project_state::ProjectStateGuard>,
5998 recovery: &Path,
5999 staged: &[(PathBuf, PathBuf)],
6000 operation: CliError,
6001) -> CliError {
6002 for (target, recovery_file) in staged.iter().rev() {
6003 if let Err(error) = verify_reset_admission(db, admission) {
6004 return retained_reset_recovery(recovery, &operation, &error.to_string());
6005 }
6006 if let Err(source) = move_reset_file_noclobber(recovery_file, target) {
6007 let kind = source.kind();
6008 return CliError::Io {
6009 path: recovery.to_path_buf(),
6010 source: io::Error::new(
6011 kind,
6012 format!("{operation}; reset recovery restore failed: {source}"),
6013 ),
6014 };
6015 }
6016 }
6017 drop(fs::remove_dir(recovery));
6019 operation
6020}
6021
6022pub(crate) fn resolved_mcp_config_path(
6024 db: &Path,
6025 config: Option<&Path>,
6026) -> Result<Option<PathBuf>, CliError> {
6027 let admission = preflight_database_discovery(db, config, true)?;
6028 let db = admitted_database_path(db, admission.as_ref());
6029 let legacy_root = legacy_project_root_candidate(db, admission.as_ref())?;
6030 if let Some(path) = config {
6031 return Ok(Some(absolute_path(path)?));
6032 }
6033 let mut candidate_roots = Vec::new();
6034 if db.exists()
6035 && let Some(project_root) = read_project_root_identity_read_only(db)?
6036 {
6037 candidate_roots.push(project_root.into_path());
6038 }
6039 if let Some(project_root) = legacy_root {
6040 candidate_roots.push(project_root);
6041 }
6042 let absolute_db = absolute_path(db)?;
6043 if let Some(project_root) = project_root_from_db_path(&absolute_db) {
6044 candidate_roots.push(project_root);
6045 }
6046 for root in candidate_roots {
6047 for candidate in config_candidates_for_root(&root) {
6048 if candidate.exists() {
6049 return Ok(Some(absolute_path(&candidate)?));
6050 }
6051 }
6052 }
6053 Ok(None)
6054}
6055
6056fn config_candidates_for_root(root: &Path) -> [PathBuf; 2] {
6058 [
6059 root.join(".projectatlas").join("config.toml"),
6060 root.join("projectatlas.toml"),
6061 ]
6062}
6063
6064pub(crate) fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
6066 if path.is_absolute() {
6067 return Ok(path.to_path_buf());
6068 }
6069 let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
6070 path: PathBuf::from("."),
6071 source,
6072 })?;
6073 Ok(current_dir.join(path))
6074}
6075
6076pub(crate) fn path_status(path: &Path) -> Result<PathStatus, CliError> {
6078 let absolute = absolute_path(path)?;
6079 let metadata = fs::metadata(&absolute).ok();
6080 Ok(PathStatus {
6081 path: lossless_native_path_display(&absolute),
6082 exists: metadata.is_some(),
6083 size_bytes: metadata
6084 .as_ref()
6085 .and_then(|metadata| metadata.is_file().then_some(metadata.len())),
6086 })
6087}
6088
6089pub(crate) fn db_sidecar_path(db: &Path, suffix: &str) -> PathBuf {
6091 let mut sidecar = db.as_os_str().to_os_string();
6092 sidecar.push(format!("-{suffix}"));
6093 PathBuf::from(sidecar)
6094}
6095
6096pub(crate) fn mcp_config_path_for_db(db: &Path) -> PathBuf {
6098 db.parent().map_or_else(
6099 || PathBuf::from("projectatlas.mcp.json"),
6100 |parent| parent.join("projectatlas.mcp.json"),
6101 )
6102}
6103
6104pub(crate) fn watcher_status_report(active: bool) -> WatchStatusReport {
6106 let notify_available = notify_runtime_available();
6107 let mode = if notify_available {
6108 WATCH_MODE_NOTIFY
6109 } else {
6110 WATCH_MODE_POLLING
6111 };
6112 let recommendation = if notify_available {
6113 "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for event-backed refresh with portable polling fallback."
6114 } else {
6115 "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for portable polling refresh."
6116 };
6117 WatchStatusReport {
6118 available: true,
6119 active,
6120 mode: mode.to_string(),
6121 event_backend_available: notify_available,
6122 recommendation: recommendation.to_string(),
6123 }
6124}
6125
6126#[derive(Debug, Eq, PartialEq, Serialize)]
6128pub(crate) struct DatabaseLintReport {
6129 pub(crate) purpose_level: String,
6131 pub(crate) shown: usize,
6133 pub(crate) total: usize,
6135 pub(crate) findings: Vec<HealthFinding>,
6137}
6138
6139impl DatabaseLintReport {
6140 fn render_text(&self) -> Result<String, CliError> {
6142 if self.findings.is_empty() {
6143 return Ok(String::new());
6144 }
6145 let mut report = format!(
6146 "ProjectAtlas SQLite index health findings (purpose-level {}, showing {} of {}):\n",
6147 self.purpose_level, self.shown, self.total
6148 );
6149 for finding in &self.findings {
6150 writeln!(
6151 &mut report,
6152 "- [{}] {}: {}",
6153 finding.category, finding.path, finding.recommendation
6154 )
6155 .map_err(|source| CliError::Output(io::Error::other(source.to_string())))?;
6156 }
6157 Ok(report)
6158 }
6159}
6160
6161#[derive(Debug, Eq, PartialEq, Serialize)]
6163pub(crate) struct LintReport {
6164 pub(crate) ok: bool,
6166 pub(crate) exit_code: i32,
6168 pub(crate) map: atlas_map::MapLintReport,
6170 pub(crate) index: Option<DatabaseLintReport>,
6172 pub(crate) report: String,
6174}
6175
6176pub(crate) fn lint_project(
6178 config: &atlas_map::AtlasMapConfig,
6179 db: &Path,
6180 config_path: Option<&Path>,
6181 options: atlas_map::LintOptions,
6182 purpose_level: PurposeLintLevel,
6183) -> Result<LintReport, CliError> {
6184 let map = atlas_map::lint_map(config, options)?;
6185 let index = lint_database_if_present(db, &config.root, config_path, purpose_level)?;
6186 let exit_code = map.exit_code().max(i32::from(
6187 index
6188 .as_ref()
6189 .is_some_and(|report| !report.findings.is_empty()),
6190 ));
6191 let mut report = map.render_text();
6192 if let Some(index) = &index {
6193 let index_text = index.render_text()?;
6194 if !index_text.is_empty() {
6195 if !report.is_empty() && !report.ends_with('\n') {
6196 report.push('\n');
6197 }
6198 report.push_str(&index_text);
6199 }
6200 }
6201 Ok(LintReport {
6202 ok: exit_code == 0,
6203 exit_code,
6204 map,
6205 index,
6206 report,
6207 })
6208}
6209
6210pub(crate) fn lint_database_if_present(
6212 db: &Path,
6213 root: &Path,
6214 config_path: Option<&Path>,
6215 purpose_level: PurposeLintLevel,
6216) -> Result<Option<DatabaseLintReport>, CliError> {
6217 match db.try_exists() {
6218 Ok(false) => return Ok(None),
6219 Ok(true) => {}
6220 Err(source) => {
6221 return Err(CliError::Io {
6222 path: db.to_path_buf(),
6223 source,
6224 });
6225 }
6226 }
6227 let control = standalone_index_work_control();
6228 let exact = open_exact_saved_source_matches_index_controlled(db, root, config_path, &control)?;
6229 let store = &exact.store;
6230 let query = purpose_level.health_query();
6231 let page = store.unresolved_health_findings_page_current(&query)?;
6232 let blocking = page
6233 .findings
6234 .into_iter()
6235 .filter(|finding| purpose_level.blocks_category(finding.category.as_str()))
6236 .collect::<Vec<_>>();
6237 let report = DatabaseLintReport {
6238 purpose_level: purpose_level.as_str().to_string(),
6239 shown: blocking.len(),
6240 total: page.total,
6241 findings: blocking,
6242 };
6243 store.finish_index_read_snapshot()?;
6244 Ok(Some(report))
6245}
6246
6247#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6249pub(crate) enum PurposeLintLevel {
6250 Low,
6252 Medium,
6254 Strict,
6256}
6257
6258impl PurposeLintLevel {
6259 pub(crate) fn as_str(self) -> &'static str {
6261 match self {
6262 Self::Low => "low",
6263 Self::Medium => "medium",
6264 Self::Strict => "strict",
6265 }
6266 }
6267
6268 fn health_query(self) -> HealthQuery {
6270 let scope = match self {
6271 Self::Low => HealthScope::purpose_default(),
6272 Self::Medium => HealthScope::purpose_with_source_files(),
6273 Self::Strict => HealthScope::purpose_strict(),
6274 };
6275 HealthQuery {
6276 start_index: 0,
6277 limit: MAX_HEALTH_LIMIT,
6278 category: None,
6279 severity: Some(Severity::Warning),
6280 path_prefix: None,
6281 summary_only: false,
6282 scope,
6283 }
6284 }
6285
6286 fn blocks_category(self, category: &str) -> bool {
6288 match category {
6289 CATEGORY_STALE_PURPOSE
6290 | CATEGORY_DUPLICATE_PURPOSE
6291 | CATEGORY_REPEATED_TEMPORARY_FOLDER => true,
6292 CATEGORY_MISSING_PURPOSE
6293 | CATEGORY_SUGGESTED_PURPOSE_REVIEW
6294 | CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self != Self::Low,
6295 _ => false,
6296 }
6297 }
6298}
6299
6300pub(crate) fn notify_runtime_available() -> bool {
6302 let (sender, _receiver) = mpsc::channel();
6303 RecommendedWatcher::new(
6304 move |result: notify::Result<Event>| {
6305 if sender.send(result).is_err() {
6306 }
6308 },
6309 Config::default(),
6310 )
6311 .is_ok()
6312}
6313
6314#[derive(Clone, Copy, Debug)]
6316pub(crate) struct SymbolBuildOptions {
6317 pub(crate) max_bytes: u64,
6319 max_workers: Option<usize>,
6321 timeout: Option<Duration>,
6323 pub(crate) timeout_seconds: Option<u64>,
6325}
6326
6327impl SymbolBuildOptions {
6328 pub(crate) fn new(
6330 max_bytes: u64,
6331 max_workers: Option<usize>,
6332 timeout_seconds: Option<u64>,
6333 ) -> Self {
6334 Self {
6335 max_bytes: max_bytes.min(MAX_SYMBOL_FILE_BYTES),
6336 max_workers: max_workers.filter(|workers| *workers > 0),
6337 timeout: timeout_seconds.map(Duration::from_secs),
6338 timeout_seconds,
6339 }
6340 }
6341
6342 #[must_use]
6344 pub(crate) fn with_worker_ceiling(mut self, max_workers: usize) -> Self {
6345 let ceiling = max_workers.max(1);
6346 self.max_workers = Some(
6347 self.max_workers
6348 .map_or(ceiling, |workers| workers.min(ceiling)),
6349 );
6350 self
6351 }
6352
6353 pub(crate) fn reported_workers(self) -> usize {
6355 self.effective_workers()
6356 }
6357
6358 fn effective_workers(self) -> usize {
6360 let available = thread::available_parallelism().map_or(1, usize::from);
6361 self.max_workers
6362 .unwrap_or(available)
6363 .min(available)
6364 .min(INDEX_WORKER_SAFE_CEILING)
6365 }
6366
6367 pub(crate) fn is_timed_out(self, started_at: Instant) -> bool {
6369 self.timeout
6370 .is_some_and(|timeout| started_at.elapsed() >= timeout)
6371 }
6372}
6373
6374fn worker_count_for_work(work_items: usize, max_workers: usize) -> usize {
6376 work_items.min(max_workers.clamp(1, INDEX_WORKER_SAFE_CEILING))
6377}
6378
6379#[derive(Clone, Copy, Debug)]
6381struct SymbolPublicationLimits {
6382 symbol_rows: u64,
6384 relation_rows: u64,
6386 output_bytes: u64,
6388}
6389
6390impl SymbolPublicationLimits {
6391 const STANDARD: Self = Self {
6393 symbol_rows: 2_000_000,
6394 relation_rows: 8_000_000,
6395 output_bytes: MAX_PUBLICATION_STAGING_BYTES,
6396 };
6397}
6398
6399pub(crate) fn index_work_control(options: &SymbolBuildOptions) -> IndexWorkControl {
6401 IndexWorkControl::new(IndexCancellation::new(), options.timeout)
6402 .with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6403 .with_worker_ceiling(options.effective_workers())
6404}
6405
6406pub(crate) fn standalone_index_work_control() -> IndexWorkControl {
6408 IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_INDEX_WORK_TIMEOUT))
6409}
6410
6411fn bounded_index_work_control(control: &IndexWorkControl) -> IndexWorkControl {
6413 control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
6414}
6415
6416#[derive(Clone, Debug)]
6418pub(crate) struct SymbolParseJob {
6419 pub(crate) path: String,
6421 native_path: PathBuf,
6423 expected_content_hash: String,
6425 language: Option<String>,
6427 fallback_summary: Option<String>,
6429 purpose_needs_suggestion: bool,
6431}
6432
6433#[derive(Debug)]
6435pub(crate) struct SymbolParseSuccess {
6436 pub(crate) path: String,
6438 graph: SymbolGraph,
6440 markdown_facts: Option<Box<MarkdownFacts>>,
6442 source_semantics: Option<Box<SourceSemanticFacts>>,
6444 source_parser: ParserKind,
6446 summary: String,
6448 summary_is_structural: bool,
6450 purpose_suggestion: Option<String>,
6452}
6453
6454#[derive(Debug)]
6456pub(crate) enum SymbolParseOutcome {
6457 Parsed(SymbolParseSuccess),
6459 BinaryOrNonUtf8 {
6461 path: String,
6463 },
6464 InvalidInput {
6466 path: String,
6468 message: String,
6470 },
6471 SourceChanged {
6473 path: String,
6475 },
6476 Io {
6478 path: PathBuf,
6480 source: io::Error,
6482 },
6483 IndexWork(IndexWorkFailure),
6485}
6486
6487#[derive(Debug)]
6489enum SourceReadFailure {
6490 Io(io::Error),
6492 IndexWork(IndexWorkFailure),
6494 LimitExceeded {
6496 observed: u64,
6498 },
6499}
6500
6501fn read_source_bytes_controlled(
6503 path: &Path,
6504 max_bytes: u64,
6505 stage: IndexWorkStage,
6506 control: &IndexWorkControl,
6507) -> Result<Vec<u8>, SourceReadFailure> {
6508 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6509 let mut file = fs::File::open(path).map_err(SourceReadFailure::Io)?;
6510 let mut bytes = Vec::new();
6511 let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
6512 loop {
6513 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6514 let count = file.read(&mut buffer).map_err(SourceReadFailure::Io)?;
6515 if count == 0 {
6516 break;
6517 }
6518 let observed = u64::try_from(bytes.len())
6519 .unwrap_or(u64::MAX)
6520 .saturating_add(count as u64);
6521 if observed > max_bytes {
6522 return Err(SourceReadFailure::LimitExceeded { observed });
6523 }
6524 bytes.extend_from_slice(&buffer[..count]);
6525 }
6526 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
6527 Ok(bytes)
6528}
6529
6530#[cfg(test)]
6532fn build_symbols_for_paths_with_limits(
6533 store: &mut AtlasStore,
6534 root: &Path,
6535 options: &SymbolBuildOptions,
6536 previous_hashes: Option<&HashMap<String, String>>,
6537 target_paths: Option<&HashSet<String>>,
6538 control: &IndexWorkControl,
6539 limits: SymbolPublicationLimits,
6540) -> Result<SymbolBuildReport, CliError> {
6541 let nodes = if let Some(paths) = target_paths {
6542 let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
6543 sorted_paths.sort();
6544 store
6545 .load_nodes_by_paths(&sorted_paths)?
6546 .into_iter()
6547 .map(|indexed| indexed.node)
6548 .collect::<Vec<_>>()
6549 } else {
6550 store
6551 .load_nodes()?
6552 .into_iter()
6553 .map(|indexed| indexed.node)
6554 .collect::<Vec<_>>()
6555 };
6556 #[cfg(feature = "optional-parser-supervisor")]
6557 let optional_parser_selection =
6558 OptionalParserPackLifecycle::new(root, None)?.derive_project_selection()?;
6559 let staged = stage_symbols_for_nodes_with_limits(
6560 store,
6561 root,
6562 #[cfg(feature = "optional-parser-supervisor")]
6563 &optional_parser_selection,
6564 &nodes,
6565 options,
6566 previous_hashes,
6567 target_paths,
6568 &[],
6569 &HashSet::new(),
6570 control,
6571 limits,
6572 )?;
6573 let mut staged = staged;
6574 apply_symbol_build_stage(store, &mut staged, control)?;
6575 Ok(staged.report)
6576}
6577
6578fn source_input_byte_limit(
6580 path: &str,
6581 language: Option<&str>,
6582 observed: Option<u64>,
6583 source_limit: u64,
6584 stage: IndexWorkStage,
6585) -> Result<u64, IndexWorkFailure> {
6586 if document_format_for_path(path, language).is_none() {
6587 return Ok(source_limit);
6588 }
6589 let maximum = MAX_DOCUMENT_COMPRESSED_BYTES as u64;
6590 if let Some(observed) = observed.filter(|observed| *observed > maximum) {
6591 return Err(IndexWorkFailure::resource_limit(
6592 stage,
6593 IndexWorkResource::SourceBytes,
6594 maximum,
6595 observed,
6596 ));
6597 }
6598 Ok(maximum)
6599}
6600
6601#[allow(clippy::too_many_arguments)]
6603fn stage_symbols_for_nodes_with_limits(
6604 store: &AtlasStore,
6605 root: &Path,
6606 #[cfg(feature = "optional-parser-supervisor")]
6607 optional_parser_selection: &OptionalParserPackProjectSelection,
6608 nodes: &[Node],
6609 options: &SymbolBuildOptions,
6610 previous_hashes: Option<&HashMap<String, String>>,
6611 target_paths: Option<&HashSet<String>>,
6612 staged_text: &[TextIndexRow],
6613 protected_purpose_paths: &HashSet<String>,
6614 control: &IndexWorkControl,
6615 limits: SymbolPublicationLimits,
6616) -> Result<SymbolBuildStage, CliError> {
6617 control.check(IndexWorkStage::SymbolParsing)?;
6618 #[cfg(feature = "optional-parser-supervisor")]
6619 let admit_optional_languages = optional_parser_selection.selection_key().is_some();
6620 #[cfg(not(feature = "optional-parser-supervisor"))]
6621 let admit_optional_languages = false;
6622 let root = root.canonicalize().map_err(|source| CliError::Io {
6623 path: root.to_path_buf(),
6624 source,
6625 })?;
6626 let considered_paths = nodes
6627 .iter()
6628 .filter(|node| node.kind == NodeKind::File)
6629 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6630 .map(|node| node.path.clone())
6631 .collect::<Vec<_>>();
6632 let previously_parsed_paths = store.source_parse_metadata_paths_for_paths(&considered_paths)?;
6633 let mut candidate_paths = nodes
6634 .iter()
6635 .filter(|node| node.kind == NodeKind::File)
6636 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6637 .filter(|node| {
6638 is_symbol_candidate_for_admission(
6639 &node.path,
6640 node.language.as_deref(),
6641 admit_optional_languages,
6642 )
6643 })
6644 .map(|node| node.path.clone())
6645 .collect::<Vec<_>>();
6646 candidate_paths.sort();
6647 let existing_nodes = store
6648 .load_nodes_by_paths(&candidate_paths)?
6649 .into_iter()
6650 .map(|indexed| (indexed.node.path.clone(), indexed))
6651 .collect::<HashMap<_, _>>();
6652 let symbol_counts = store.symbol_counts_for_paths(&candidate_paths)?;
6653 let mut report = SymbolBuildReport {
6654 candidates: 0,
6655 parsed: 0,
6656 unchanged: 0,
6657 too_large: 0,
6658 binary_or_non_utf8: 0,
6659 timed_out: 0,
6660 max_workers: options.reported_workers(),
6661 timeout_seconds: options.timeout_seconds,
6662 symbols: 0,
6663 relations: 0,
6664 summaries: 0,
6665 purpose_suggestions: 0,
6666 };
6667 let mut jobs = Vec::new();
6668 let mut changes = Vec::new();
6669 let mut output_bytes = 0_u64;
6670 for node in nodes
6671 .iter()
6672 .filter(|node| node.kind == NodeKind::File)
6673 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6674 .filter(|node| {
6675 !is_symbol_candidate_for_admission(
6676 &node.path,
6677 node.language.as_deref(),
6678 admit_optional_languages,
6679 ) && previously_parsed_paths.contains(&node.path)
6680 })
6681 {
6682 output_bytes = checked_symbol_publication_usage(
6683 output_bytes,
6684 node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6685 limits.output_bytes,
6686 IndexWorkResource::OutputBytes,
6687 )?;
6688 changes.push(SymbolProjectionChange::Clear {
6689 path: node.path.clone(),
6690 language: node.language.clone(),
6691 });
6692 }
6693 for node in nodes
6694 .iter()
6695 .filter(|node| node.kind == NodeKind::File)
6696 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
6697 .filter(|node| {
6698 is_symbol_candidate_for_admission(
6699 &node.path,
6700 node.language.as_deref(),
6701 admit_optional_languages,
6702 )
6703 })
6704 {
6705 control.check(IndexWorkStage::SymbolParsing)?;
6706 report.candidates += 1;
6707 let max_bytes = source_input_byte_limit(
6708 &node.path,
6709 node.language.as_deref(),
6710 node.size_bytes,
6711 options.max_bytes,
6712 IndexWorkStage::SymbolParsing,
6713 )?;
6714 if node.size_bytes.is_some_and(|size| size > max_bytes) {
6715 output_bytes = checked_symbol_publication_usage(
6716 output_bytes,
6717 node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
6718 limits.output_bytes,
6719 IndexWorkResource::OutputBytes,
6720 )?;
6721 changes.push(SymbolProjectionChange::Clear {
6722 path: node.path.clone(),
6723 language: node.language.clone(),
6724 });
6725 report.too_large += 1;
6726 continue;
6727 }
6728 let symbol_count = symbol_counts.get(&node.path).copied().unwrap_or_default();
6729 if node.content_hash.as_ref().is_some_and(|hash| {
6730 previous_hashes.and_then(|hashes| hashes.get(&node.path)) == Some(hash)
6731 }) {
6732 let has_source_index =
6733 symbol_count > 0 || store.load_source_parse_metadata(&node.path)?.is_some();
6734 if has_source_index {
6735 report.unchanged += 1;
6736 continue;
6737 }
6738 }
6739 let existing = existing_nodes.get(&node.path);
6740 jobs.push(SymbolParseJob {
6741 path: node.path.clone(),
6742 native_path: root.join(repo_path_to_native(&node.path)),
6743 expected_content_hash: node
6744 .content_hash
6745 .clone()
6746 .ok_or_else(|| source_changed_during_derivation(&root, &node.path))?,
6747 language: node.language.clone(),
6748 fallback_summary: existing.and_then(|indexed| indexed.summary.clone()),
6749 purpose_needs_suggestion: !protected_purpose_paths.contains(&node.path)
6750 && existing.is_none_or(|indexed| {
6751 matches!(
6752 indexed.purpose.status,
6753 PurposeStatus::Missing | PurposeStatus::Suggested
6754 )
6755 }),
6756 });
6757 if jobs.len() > MAX_SYMBOL_PARSE_JOBS {
6758 return Err(IndexWorkFailure::resource_limit(
6759 IndexWorkStage::SymbolParsing,
6760 IndexWorkResource::SymbolJobs,
6761 MAX_SYMBOL_PARSE_JOBS as u64,
6762 jobs.len() as u64,
6763 )
6764 .into());
6765 }
6766 }
6767 report.max_workers = worker_count_for_work(jobs.len(), report.max_workers);
6768 if !jobs.is_empty() {
6769 let verified_text = staged_text
6770 .iter()
6771 .filter_map(|row| row.text.as_ref().map(|text| (row.path.as_str(), text)))
6772 .collect::<HashMap<_, _>>();
6773 let pool = ThreadPoolBuilder::new()
6774 .num_threads(report.max_workers)
6775 .build()
6776 .map_err(|source| {
6777 CliError::InvalidInput(format!("symbol worker pool failed: {source}"))
6778 })?;
6779 #[cfg(feature = "optional-parser-supervisor")]
6780 let outcomes = optional_parser_runtime::parse_symbol_jobs_controlled(
6781 &root,
6782 optional_parser_selection,
6783 &pool,
6784 &jobs,
6785 options,
6786 control,
6787 &verified_text,
6788 )?;
6789 #[cfg(not(feature = "optional-parser-supervisor"))]
6790 let outcomes =
6791 parse_symbol_job_batches_controlled(&pool, &jobs, options, control, &verified_text)?;
6792 for outcome in outcomes {
6793 match outcome {
6794 SymbolParseOutcome::Parsed(parsed) => {
6795 let next_symbols = checked_symbol_publication_usage(
6796 report.symbols as u64,
6797 graph_projection::navigable_symbol_count(&parsed.graph) as u64,
6798 limits.symbol_rows,
6799 IndexWorkResource::SymbolRows,
6800 )?;
6801 let next_relations = checked_symbol_publication_usage(
6802 report.relations as u64,
6803 parsed.graph.relations.len() as u64,
6804 limits.relation_rows,
6805 IndexWorkResource::RelationRows,
6806 )?;
6807 let next_output_bytes = checked_symbol_publication_usage(
6808 output_bytes,
6809 symbol_parse_output_bytes(&parsed),
6810 limits.output_bytes,
6811 IndexWorkResource::OutputBytes,
6812 )?;
6813 report.summaries += 1;
6814 if parsed.purpose_suggestion.is_some() {
6815 report.purpose_suggestions += 1;
6816 }
6817 report.parsed += 1;
6818 report.symbols = next_symbols as usize;
6819 report.relations = next_relations as usize;
6820 output_bytes = next_output_bytes;
6821 changes.push(SymbolProjectionChange::Parsed(parsed));
6822 }
6823 SymbolParseOutcome::BinaryOrNonUtf8 { path } => {
6824 let language = nodes
6825 .iter()
6826 .find(|node| node.path == path)
6827 .and_then(|node| node.language.clone());
6828 output_bytes = checked_symbol_publication_usage(
6829 output_bytes,
6830 path.len() as u64 + language.as_ref().map_or(0, String::len) as u64,
6831 limits.output_bytes,
6832 IndexWorkResource::OutputBytes,
6833 )?;
6834 changes.push(SymbolProjectionChange::Clear { path, language });
6835 report.binary_or_non_utf8 += 1;
6836 }
6837 SymbolParseOutcome::InvalidInput { path, message } => {
6838 return Err(CliError::InvalidInput(format!(
6839 "document extraction failed for {path}: {message}"
6840 )));
6841 }
6842 SymbolParseOutcome::SourceChanged { path } => {
6843 return Err(source_changed_during_derivation(&root, &path));
6844 }
6845 SymbolParseOutcome::Io { path, source } => {
6846 return Err(CliError::Io { path, source });
6847 }
6848 SymbolParseOutcome::IndexWork(failure) => return Err(failure.into()),
6849 }
6850 }
6851 }
6852 control.check(IndexWorkStage::SymbolParsing)?;
6853 let mut staged = SymbolBuildStage {
6854 report,
6855 changes,
6856 retained_bytes: output_bytes,
6857 identity_admission: graph_projection::GraphIdentityAdmission::default(),
6858 };
6859 staged.identity_admission = graph_projection::admit_symbol_build_stage_with_limit(
6860 &mut staged,
6861 control,
6862 limits.output_bytes.saturating_sub(output_bytes),
6863 )?;
6864 staged.retained_bytes = staged
6865 .retained_bytes
6866 .saturating_add(staged.identity_admission.retained_bytes());
6867 Ok(staged)
6868}
6869
6870#[cfg(not(feature = "optional-parser-supervisor"))]
6872fn parse_symbol_job_batches_controlled(
6873 pool: &rayon::ThreadPool,
6874 jobs: &[SymbolParseJob],
6875 options: &SymbolBuildOptions,
6876 control: &IndexWorkControl,
6877 verified_text: &HashMap<&str, &IndexedFileText>,
6878) -> Result<Vec<SymbolParseOutcome>, CliError> {
6879 let mut outcomes = Vec::with_capacity(jobs.len());
6880 for batch in jobs.chunks(SYMBOL_PARSE_BATCH_SIZE) {
6881 control.check(IndexWorkStage::SymbolParsing)?;
6882 outcomes.extend(parse_symbol_jobs_controlled(
6883 pool,
6884 batch,
6885 options,
6886 control,
6887 verified_text,
6888 )?);
6889 }
6890 Ok(outcomes)
6891}
6892
6893fn apply_symbol_build_stage(
6895 store: &mut AtlasStore,
6896 staged: &mut SymbolBuildStage,
6897 control: &IndexWorkControl,
6898) -> Result<(), CliError> {
6899 if !staged.identity_admission.source_admitted() {
6900 staged.identity_admission = graph_projection::admit_symbol_build_stage_with_limit(
6901 staged,
6902 control,
6903 MAX_PUBLICATION_STAGING_BYTES.saturating_sub(staged.retained_bytes),
6904 )?;
6905 staged.retained_bytes = staged
6906 .retained_bytes
6907 .saturating_add(staged.identity_admission.retained_bytes());
6908 }
6909 for change in &staged.changes {
6910 control.check(IndexWorkStage::Publication)?;
6911 match change {
6912 SymbolProjectionChange::Parsed(parsed) => {
6913 store.set_node_summary(&parsed.path, &parsed.summary)?;
6914 if let Some(suggestion) = parsed.purpose_suggestion.as_deref() {
6915 store.set_suggested_purpose(&parsed.path, suggestion)?;
6916 }
6917 let mut metadata = SourceParseMetadata::from_graph(&parsed.graph);
6918 metadata.parser = parsed.source_parser;
6919 store.replace_symbol_graph_with_metadata(&parsed.graph, &metadata)?;
6920 }
6921 SymbolProjectionChange::Clear { path, language } => {
6922 clear_skipped_symbol_index(store, path, language.as_deref())?;
6923 }
6924 }
6925 }
6926 control.check(IndexWorkStage::Publication)?;
6927 Ok(())
6928}
6929
6930#[cfg(not(feature = "optional-parser-supervisor"))]
6932fn parse_symbol_jobs_controlled(
6933 pool: &rayon::ThreadPool,
6934 jobs: &[SymbolParseJob],
6935 options: &SymbolBuildOptions,
6936 control: &IndexWorkControl,
6937 verified_text: &HashMap<&str, &IndexedFileText>,
6938) -> Result<Vec<SymbolParseOutcome>, CliError> {
6939 control.check(IndexWorkStage::SymbolParsing)?;
6940 Ok(pool.install(|| {
6941 jobs.par_iter()
6942 .map(|job| {
6943 parse_symbol_job_with_staged_text_controlled(
6944 job,
6945 options,
6946 control,
6947 verified_text.get(job.path.as_str()).copied(),
6948 )
6949 })
6950 .collect::<Vec<_>>()
6951 }))
6952}
6953
6954fn checked_symbol_publication_usage(
6956 current: u64,
6957 added: u64,
6958 limit: u64,
6959 resource: IndexWorkResource,
6960) -> Result<u64, CliError> {
6961 let observed = current.saturating_add(added);
6962 if observed > limit {
6963 return Err(IndexWorkFailure::resource_limit(
6964 IndexWorkStage::SymbolParsing,
6965 resource,
6966 limit,
6967 observed,
6968 )
6969 .into());
6970 }
6971 Ok(observed)
6972}
6973
6974fn symbol_parse_output_bytes(parsed: &SymbolParseSuccess) -> u64 {
6976 let graph = &parsed.graph;
6977 let mut bytes = graph.path.len() as u64
6978 + graph.language.as_ref().map_or(0, String::len) as u64
6979 + parsed.summary.len() as u64
6980 + parsed.purpose_suggestion.as_ref().map_or(0, String::len) as u64;
6981 for symbol in &graph.symbols {
6982 if graph_projection::is_document_coverage_marker(graph, symbol) {
6983 continue;
6984 }
6985 bytes = bytes.saturating_add(
6986 symbol.path.len() as u64
6987 + symbol.language.as_ref().map_or(0, String::len) as u64
6988 + symbol.name.len() as u64
6989 + symbol.signature.len() as u64
6990 + symbol.documentation.as_ref().map_or(0, String::len) as u64
6991 + symbol.parent.as_ref().map_or(0, String::len) as u64
6992 + symbol.detail.as_ref().map_or(0, String::len) as u64,
6993 );
6994 }
6995 for relation in &graph.relations {
6996 bytes = bytes.saturating_add(
6997 relation.path.len() as u64
6998 + relation.source_name.len() as u64
6999 + relation.target_name.len() as u64
7000 + relation.context.len() as u64,
7001 );
7002 }
7003 if let Some(facts) = &parsed.source_semantics {
7004 bytes = bytes.saturating_add(facts.retained_bytes());
7005 }
7006 if let Some(facts) = &parsed.markdown_facts {
7007 for heading in &facts.headings {
7008 bytes = bytes
7009 .saturating_add(heading.text.len() as u64)
7010 .saturating_add(heading.slug.len() as u64);
7011 }
7012 for candidate in &facts.link_candidates {
7013 bytes = bytes
7014 .saturating_add(candidate.selector.len() as u64)
7015 .saturating_add(candidate.label.as_ref().map_or(0, String::len) as u64)
7016 .saturating_add(candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64);
7017 }
7018 }
7019 bytes
7020}
7021
7022#[cfg(test)]
7024pub(crate) fn parse_symbol_job(
7025 job: &SymbolParseJob,
7026 options: &SymbolBuildOptions,
7027 started_at: Instant,
7028) -> SymbolParseOutcome {
7029 let control = options
7030 .timeout
7031 .and_then(|timeout| started_at.checked_add(timeout))
7032 .map_or_else(
7033 || IndexWorkControl::new(IndexCancellation::new(), None),
7034 |deadline| IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
7035 );
7036 parse_symbol_job_controlled(job, options, &control)
7037}
7038
7039fn parse_symbol_job_with_staged_text_controlled(
7041 job: &SymbolParseJob,
7042 options: &SymbolBuildOptions,
7043 control: &IndexWorkControl,
7044 verified_text: Option<&IndexedFileText>,
7045) -> SymbolParseOutcome {
7046 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
7047 return SymbolParseOutcome::IndexWork(failure);
7048 }
7049 if document_format_for_path(&job.path, job.language.as_deref()).is_none()
7050 && let Some(text) = verified_text
7051 && text.path == job.path
7052 && text.content_hash.as_deref() == Some(job.expected_content_hash.as_str())
7053 && text.content.len() as u64 <= options.max_bytes
7054 {
7055 return parse_admitted_symbol_job(job, &text.content, None, options, control);
7056 }
7057 parse_symbol_job_controlled(job, options, control)
7058}
7059
7060fn parse_symbol_job_controlled(
7062 job: &SymbolParseJob,
7063 options: &SymbolBuildOptions,
7064 control: &IndexWorkControl,
7065) -> SymbolParseOutcome {
7066 if options.is_timed_out(control.started_at()) {
7067 return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
7068 stage: IndexWorkStage::SymbolParsing,
7069 });
7070 }
7071 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
7072 return SymbolParseOutcome::IndexWork(failure);
7073 }
7074 let bytes = match admit_symbol_job_bytes(job, options, control) {
7075 Ok(bytes) => bytes,
7076 Err(outcome) => return *outcome,
7077 };
7078 if document_format_for_path(&job.path, job.language.as_deref()).is_some() {
7079 return parse_document_symbol_job(job, &bytes, options, control);
7080 }
7081 let Ok(content) = String::from_utf8(bytes) else {
7082 return SymbolParseOutcome::BinaryOrNonUtf8 {
7083 path: job.path.clone(),
7084 };
7085 };
7086 parse_admitted_symbol_job(job, &content, None, options, control)
7087}
7088
7089fn admit_symbol_job_bytes(
7091 job: &SymbolParseJob,
7092 options: &SymbolBuildOptions,
7093 control: &IndexWorkControl,
7094) -> Result<Vec<u8>, Box<SymbolParseOutcome>> {
7095 let max_bytes = source_input_byte_limit(
7096 &job.path,
7097 job.language.as_deref(),
7098 None,
7099 options.max_bytes,
7100 IndexWorkStage::SymbolParsing,
7101 )
7102 .map_err(|failure| Box::new(SymbolParseOutcome::IndexWork(failure)))?;
7103 let bytes = match read_source_bytes_controlled(
7104 &job.native_path,
7105 max_bytes,
7106 IndexWorkStage::SymbolParsing,
7107 control,
7108 ) {
7109 Ok(bytes) => bytes,
7110 Err(SourceReadFailure::Io(source)) => {
7111 return Err(Box::new(SymbolParseOutcome::Io {
7112 path: job.native_path.clone(),
7113 source,
7114 }));
7115 }
7116 Err(SourceReadFailure::IndexWork(failure)) => {
7117 return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
7118 }
7119 Err(SourceReadFailure::LimitExceeded { observed }) => {
7120 return Err(Box::new(SymbolParseOutcome::IndexWork(
7121 IndexWorkFailure::resource_limit(
7122 IndexWorkStage::SymbolParsing,
7123 IndexWorkResource::SourceBytes,
7124 max_bytes,
7125 observed,
7126 ),
7127 )));
7128 }
7129 };
7130 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
7131 return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
7132 }
7133 if blake3::hash(&bytes).to_hex().as_str() != job.expected_content_hash {
7134 return Err(Box::new(SymbolParseOutcome::SourceChanged {
7135 path: job.path.clone(),
7136 }));
7137 }
7138 Ok(bytes)
7139}
7140
7141fn parse_document_symbol_job(
7143 job: &SymbolParseJob,
7144 bytes: &[u8],
7145 _options: &SymbolBuildOptions,
7146 control: &IndexWorkControl,
7147) -> SymbolParseOutcome {
7148 let facts = match extract_document_symbol_facts_controlled(
7149 bytes,
7150 &job.path,
7151 job.language.as_deref(),
7152 control,
7153 ) {
7154 Ok(facts) => facts,
7155 Err(error) => return document_parse_error_outcome(&job.path, error),
7156 };
7157 let summary = document_summary_from_facts(&facts);
7158 let graph = facts.symbol_graph(&job.path, job.language.as_deref());
7159 let purpose_suggestion = job
7160 .purpose_needs_suggestion
7161 .then(|| suggest_file_purpose(&job.path, &summary));
7162 SymbolParseOutcome::Parsed(SymbolParseSuccess {
7163 path: job.path.clone(),
7164 graph,
7165 markdown_facts: None,
7166 source_semantics: None,
7167 source_parser: ParserKind::Structural,
7168 summary,
7169 summary_is_structural: false,
7170 purpose_suggestion,
7171 })
7172}
7173
7174fn document_parse_error_outcome(path: &str, error: DocumentExtractionError) -> SymbolParseOutcome {
7176 match error {
7177 DocumentExtractionError::Work(failure) => SymbolParseOutcome::IndexWork(failure),
7178 DocumentExtractionError::ResourceLimit {
7179 limit,
7180 observed,
7181 maximum,
7182 } => {
7183 let resource = match limit {
7184 DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
7185 DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
7186 DocumentLimit::ParserWorkBytes => IndexWorkResource::ParserWorkBytes,
7187 DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
7188 DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
7189 IndexWorkResource::Entries
7190 }
7191 DocumentLimit::InputBytes
7192 | DocumentLimit::CompressedBytes
7193 | DocumentLimit::ExpandedBytes
7194 | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
7195 };
7196 SymbolParseOutcome::IndexWork(IndexWorkFailure::resource_limit(
7197 IndexWorkStage::SymbolParsing,
7198 resource,
7199 maximum as u64,
7200 observed as u64,
7201 ))
7202 }
7203 other => SymbolParseOutcome::InvalidInput {
7204 path: path.to_owned(),
7205 message: other.to_string(),
7206 },
7207 }
7208}
7209
7210fn document_navigation_error(path: &str, error: DocumentExtractionError) -> CliError {
7212 match error {
7213 DocumentExtractionError::Work(failure) => failure.into(),
7214 DocumentExtractionError::ResourceLimit {
7215 limit,
7216 observed,
7217 maximum,
7218 } => {
7219 let resource = match limit {
7220 DocumentLimit::FactCount => IndexWorkResource::SymbolRows,
7221 DocumentLimit::ExecutionFuel => IndexWorkResource::ParserFuel,
7222 DocumentLimit::ParserWorkBytes => IndexWorkResource::ParserWorkBytes,
7223 DocumentLimit::OutputBytes => IndexWorkResource::OutputBytes,
7224 DocumentLimit::EntryCount | DocumentLimit::NestingDepth => {
7225 IndexWorkResource::Entries
7226 }
7227 DocumentLimit::InputBytes
7228 | DocumentLimit::CompressedBytes
7229 | DocumentLimit::ExpandedBytes
7230 | DocumentLimit::MemoryBytes => IndexWorkResource::SourceBytes,
7231 };
7232 IndexWorkFailure::resource_limit(
7233 IndexWorkStage::TextIndex,
7234 resource,
7235 maximum as u64,
7236 observed as u64,
7237 )
7238 .into()
7239 }
7240 other => CliError::InvalidInput(format!("document extraction failed for {path}: {other}")),
7241 }
7242}
7243
7244fn parse_admitted_symbol_job(
7246 job: &SymbolParseJob,
7247 content: &str,
7248 source_parser: Option<ParserKind>,
7249 options: &SymbolBuildOptions,
7250 control: &IndexWorkControl,
7251) -> SymbolParseOutcome {
7252 if options.is_timed_out(control.started_at()) {
7253 return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
7254 stage: IndexWorkStage::SymbolParsing,
7255 });
7256 }
7257 let (observed_source_parser, graph, markdown_facts, source_semantics) = if job
7258 .language
7259 .as_deref()
7260 .and_then(language_capability)
7261 .is_some_and(|capability| capability.symbol_parser == SymbolParserOwner::Markdown)
7262 {
7263 let facts = match extract_markdown_facts_controlled(content, control) {
7264 Ok(facts) => facts,
7265 Err(failure) => return SymbolParseOutcome::IndexWork(failure),
7266 };
7267 let graph = facts.symbol_graph(&job.path, job.language.as_deref());
7268 (graph.parser, graph, Some(Box::new(facts)), None)
7269 } else {
7270 let (parser, graph, semantics) = match extract_symbol_graph_with_semantics_controlled(
7271 &job.path,
7272 job.language.as_deref(),
7273 content,
7274 control,
7275 ) {
7276 Ok(extraction) => extraction.into_parts(),
7277 Err(failure) => return SymbolParseOutcome::IndexWork(failure),
7278 };
7279 (parser, graph, None, Some(Box::new(semantics)))
7280 };
7281 let source_parser = source_parser.unwrap_or(observed_source_parser);
7282 let structural_summary = if let Some(facts) = &markdown_facts {
7283 markdown_summary_from_facts(
7284 facts,
7285 content
7286 .lines()
7287 .filter(|line| !line.trim().is_empty())
7288 .count(),
7289 )
7290 } else if graph.symbols.is_empty() {
7291 structural_summary_for_path(&job.path, job.language.as_deref(), content)
7292 } else {
7293 None
7294 };
7295 let summary_is_structural = structural_summary.is_some();
7296 let summary = structural_summary
7297 .unwrap_or_else(|| summarize_symbol_graph(&graph, job.fallback_summary.as_deref()));
7298 let purpose_suggestion = job
7299 .purpose_needs_suggestion
7300 .then(|| suggest_file_purpose(&job.path, &summary));
7301 SymbolParseOutcome::Parsed(SymbolParseSuccess {
7302 path: job.path.clone(),
7303 graph,
7304 markdown_facts,
7305 source_semantics,
7306 source_parser,
7307 summary,
7308 summary_is_structural,
7309 purpose_suggestion,
7310 })
7311}
7312
7313pub(crate) fn empty_symbol_build_report() -> SymbolBuildReport {
7315 SymbolBuildReport {
7316 candidates: 0,
7317 parsed: 0,
7318 unchanged: 0,
7319 too_large: 0,
7320 binary_or_non_utf8: 0,
7321 timed_out: 0,
7322 max_workers: 0,
7323 timeout_seconds: None,
7324 symbols: 0,
7325 relations: 0,
7326 summaries: 0,
7327 purpose_suggestions: 0,
7328 }
7329}
7330
7331fn empty_text_index_report(options: TextIndexOptions) -> TextIndexReport {
7333 TextIndexReport {
7334 candidates: 0,
7335 indexed: 0,
7336 binary_or_non_utf8: 0,
7337 too_large: 0,
7338 skipped: 0,
7339 max_bytes: options.max_bytes,
7340 bytes: 0,
7341 }
7342}
7343
7344pub(crate) fn summarize_symbol_graph(graph: &SymbolGraph, fallback: Option<&str>) -> String {
7346 if graph.symbols.is_empty() {
7347 if let Some(fallback) = fallback.filter(|summary| !is_scanner_fallback_summary(summary)) {
7348 return fallback.to_string();
7349 }
7350 let language = observed_language_label(graph.language.as_deref());
7351 if observed_content_noun(graph.language.as_deref()) == "document" {
7352 return format!("{language} document with no headings found.");
7353 }
7354 return format!("{language} source file with no declarations found.");
7355 }
7356 let language = observed_language_label(graph.language.as_deref());
7357 let primary_names = primary_symbol_names(graph, 4);
7358 let primary_kinds = primary_symbol_kinds(graph);
7359 let imports = relation_targets(graph, RelationKind::Imports, 2);
7360 let dependencies = relation_targets(graph, RelationKind::DependsOn, 3);
7361 if !dependencies.is_empty() {
7362 let subject = observed_manifest_subject(&language);
7363 return format!(
7364 "{subject} declaring {} and depending on {}.",
7365 primary_names.join(", "),
7366 dependencies.join(", ")
7367 );
7368 }
7369 if !imports.is_empty() {
7370 return format!(
7371 "{language} source defining {} {} with imports {}.",
7372 primary_kinds,
7373 primary_names.join(", "),
7374 imports.join(", ")
7375 );
7376 }
7377 if observed_content_noun(graph.language.as_deref()) == "document" {
7378 return format!(
7379 "{language} document with {} {}.",
7380 primary_kinds,
7381 primary_names.join(", ")
7382 );
7383 }
7384 format!(
7385 "{language} source defining {} {}.",
7386 primary_kinds,
7387 primary_names.join(", ")
7388 )
7389}
7390
7391fn observed_content_noun(language: Option<&str>) -> &'static str {
7393 if content_classification(language, true)
7394 == projectatlas_core::language::ContentClassification::Documentation
7395 {
7396 "document"
7397 } else {
7398 "source"
7399 }
7400}
7401
7402fn observed_language_label(language: Option<&str>) -> String {
7404 match language.unwrap_or("source") {
7405 "cargo-manifest" => "cargo manifest".to_string(),
7406 "cargo-lock" => "cargo lock".to_string(),
7407 "rust-build-script" => "rust build script".to_string(),
7408 "objective-c" => "Objective-C".to_string(),
7409 "csharp" => "C#".to_string(),
7410 "cpp" => "C++".to_string(),
7411 other => other.replace('-', " "),
7412 }
7413}
7414
7415fn observed_manifest_subject(language: &str) -> String {
7417 if language.contains("manifest") {
7418 language.to_string()
7419 } else {
7420 format!("{language} manifest")
7421 }
7422}
7423
7424pub(crate) fn primary_symbol_kinds(graph: &SymbolGraph) -> String {
7426 let mut function_like = 0_usize;
7427 let mut type_like = 0_usize;
7428 let mut manifest_like = 0_usize;
7429 let mut value_like = 0_usize;
7430 let mut heading_like = 0_usize;
7431 for symbol in &graph.symbols {
7432 match symbol.kind {
7433 SymbolKind::Function | SymbolKind::Method => function_like += 1,
7434 SymbolKind::Class
7435 | SymbolKind::Struct
7436 | SymbolKind::Enum
7437 | SymbolKind::Trait
7438 | SymbolKind::Interface
7439 | SymbolKind::Type => type_like += 1,
7440 SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Dependency => {
7441 manifest_like += 1;
7442 }
7443 SymbolKind::Value => value_like += 1,
7444 SymbolKind::Heading => heading_like += 1,
7445 SymbolKind::Module | SymbolKind::Import | SymbolKind::Unknown => {}
7446 }
7447 }
7448 if manifest_like > 0 && function_like == 0 && type_like == 0 {
7449 return "manifest entries".to_string();
7450 }
7451 if value_like > 0 && function_like == 0 && type_like == 0 {
7452 return value_only_symbol_kind_label(graph, value_like);
7453 }
7454 if heading_like > 0 && function_like == 0 && type_like == 0 {
7455 return if heading_like == 1 {
7456 "heading".to_string()
7457 } else {
7458 "headings".to_string()
7459 };
7460 }
7461 match (type_like, function_like) {
7462 (0, 0) => "symbols".to_string(),
7463 (0, 1) => "function".to_string(),
7464 (0, _) => "functions".to_string(),
7465 (1, 0) => "type".to_string(),
7466 (_, 0) => "types".to_string(),
7467 (1, 1) => "type and function".to_string(),
7468 (1, _) => "type and functions".to_string(),
7469 (_, 1) => "types and function".to_string(),
7470 (_, _) => "types and functions".to_string(),
7471 }
7472}
7473
7474pub(crate) fn value_only_symbol_kind_label(graph: &SymbolGraph, count: usize) -> String {
7476 let language = graph.language.as_deref().unwrap_or_default();
7477 let binding_language = matches!(
7478 language,
7479 "javascript" | "typescript" | "tsx" | "vue" | "svelte"
7480 ) || graph
7481 .symbols
7482 .iter()
7483 .any(|symbol| symbol.detail.as_deref() == Some("fallback-composition-binding"));
7484 let singular = if binding_language { "binding" } else { "value" };
7485 let plural = if binding_language {
7486 "bindings"
7487 } else {
7488 "values"
7489 };
7490 if count == 1 {
7491 singular.to_string()
7492 } else {
7493 plural.to_string()
7494 }
7495}
7496
7497pub(crate) fn primary_symbol_names(graph: &SymbolGraph, limit: usize) -> Vec<String> {
7499 let has_primary_definitions = graph.symbols.iter().any(|symbol| {
7500 matches!(
7501 symbol.kind,
7502 SymbolKind::Function
7503 | SymbolKind::Method
7504 | SymbolKind::Class
7505 | SymbolKind::Struct
7506 | SymbolKind::Enum
7507 | SymbolKind::Trait
7508 | SymbolKind::Interface
7509 | SymbolKind::Type
7510 )
7511 });
7512 let mut names = graph
7513 .symbols
7514 .iter()
7515 .filter(|symbol| {
7516 if has_primary_definitions && symbol.kind == SymbolKind::Value {
7517 return false;
7518 }
7519 !matches!(
7520 symbol.kind,
7521 SymbolKind::Import
7522 | SymbolKind::Dependency
7523 | SymbolKind::Module
7524 | SymbolKind::Unknown
7525 )
7526 })
7527 .map(|symbol| symbol.name.clone())
7528 .collect::<Vec<_>>();
7529 if names.is_empty() {
7530 names = graph
7531 .symbols
7532 .iter()
7533 .map(|symbol| symbol.name.clone())
7534 .collect::<Vec<_>>();
7535 }
7536 names.sort();
7537 names.dedup();
7538 names.truncate(limit);
7539 if names.is_empty() {
7540 vec!["indexed symbols".to_string()]
7541 } else {
7542 names
7543 }
7544}
7545
7546pub(crate) fn relation_targets(
7548 graph: &SymbolGraph,
7549 kind: RelationKind,
7550 limit: usize,
7551) -> Vec<String> {
7552 let mut targets = graph
7553 .relations
7554 .iter()
7555 .filter(|relation| relation.kind == kind)
7556 .map(|relation| relation.target_name.clone())
7557 .collect::<Vec<_>>();
7558 targets.sort();
7559 targets.dedup();
7560 targets.truncate(limit);
7561 targets
7562}
7563
7564pub(crate) fn suggest_file_purpose(path: &str, summary: &str) -> String {
7566 let subject = path_context_subject(path);
7567 if let Some(text) = summary
7568 .strip_prefix("pdf document text: ")
7569 .or_else(|| summary.strip_prefix("docx document text: "))
7570 {
7571 format!("Document {text}")
7572 } else if summary.contains("dataset manifest") {
7573 if let Some(datasets) = summary_between(summary, " including ", " and keys") {
7574 format!("Define the {subject} dataset manifest for {datasets}.")
7575 } else {
7576 format!("Define the {subject} dataset manifest.")
7577 }
7578 } else if let Some(workflow) = summary_between(summary, "yaml workflow ", " triggered") {
7579 format!("Define the {workflow} workflow.")
7580 } else if summary.contains("manifest") {
7581 if let Some(package) = summary_between(summary, " manifest for ", " with ") {
7582 format!("Define the {package} manifest.")
7583 } else {
7584 format!("Define the {subject} manifest.")
7585 }
7586 } else if let Some(title) = summary_between(summary, "document titled ", " with ") {
7587 format!("Document {title}.")
7588 } else if summary.contains("stylesheet") {
7589 format!("Style the {subject} stylesheet.")
7590 } else if summary.contains("config") {
7591 format!("Configure the {subject}.")
7592 } else if is_gradle_build_script(path) {
7593 if let Some(declarations) = summary_primary_declarations(summary) {
7594 format!("Define Gradle build tasks around {declarations}.")
7595 } else {
7596 "Configure the Gradle build.".to_string()
7597 }
7598 } else if let Some(declarations) = summary_primary_declarations(summary) {
7599 format!("Implement the {subject} source around {declarations}.")
7600 } else if summary.contains("source") {
7601 format!("Implement the {subject} source.")
7602 } else {
7603 format!("Implement the {subject}.")
7604 }
7605}
7606
7607fn is_gradle_build_script(path: &str) -> bool {
7609 let normalized = path.replace('\\', "/");
7610 normalized.ends_with("build.gradle") || normalized.ends_with("build.gradle.kts")
7611}
7612
7613fn path_context_subject(path: &str) -> String {
7615 let normalized = path.replace('\\', "/");
7616 let mut segments = normalized
7617 .split('/')
7618 .filter(|segment| !segment.is_empty() && *segment != ".")
7619 .collect::<Vec<_>>();
7620 let Some(file_name) = segments.pop() else {
7621 return "path".to_string();
7622 };
7623 let stem = file_name
7624 .rsplit_once('.')
7625 .map_or(file_name, |(stem, _)| stem);
7626 let stem_words = path_segment_words(stem);
7627 let parent_words = segments
7628 .iter()
7629 .rev()
7630 .find(|segment| !is_generic_context_segment(segment))
7631 .map(|segment| path_segment_words(segment));
7632 match parent_words {
7633 Some(parent) if !parent.is_empty() && parent != stem_words => {
7634 format!("{parent} {stem_words}")
7635 }
7636 _ => stem_words,
7637 }
7638}
7639
7640fn path_segment_words(segment: &str) -> String {
7642 let mut words = String::new();
7643 let mut previous_lowercase = false;
7644 for character in segment.chars() {
7645 if character == '-' || character == '_' || character == '.' {
7646 push_word_space(&mut words);
7647 previous_lowercase = false;
7648 continue;
7649 }
7650 if character.is_uppercase() && previous_lowercase {
7651 push_word_space(&mut words);
7652 }
7653 words.extend(character.to_lowercase());
7654 previous_lowercase = character.is_lowercase() || character.is_ascii_digit();
7655 }
7656 let words = words.trim();
7657 if words.is_empty() {
7658 "path".to_string()
7659 } else {
7660 words.to_string()
7661 }
7662}
7663
7664fn push_word_space(words: &mut String) {
7666 if !words.ends_with(' ') && !words.is_empty() {
7667 words.push(' ');
7668 }
7669}
7670
7671fn is_generic_context_segment(segment: &str) -> bool {
7673 matches!(
7674 segment.to_ascii_lowercase().as_str(),
7675 "src"
7676 | "source"
7677 | "sources"
7678 | "app"
7679 | "apps"
7680 | "lib"
7681 | "libs"
7682 | "crate"
7683 | "crates"
7684 | "package"
7685 | "packages"
7686 | "test"
7687 | "tests"
7688 | "spec"
7689 | "specs"
7690 | "fixture"
7691 | "fixtures"
7692 | "example"
7693 | "examples"
7694 | "script"
7695 | "scripts"
7696 )
7697}
7698
7699fn summary_primary_declarations(summary: &str) -> Option<String> {
7701 let after_marker = summary
7702 .split_once(" source defining ")
7703 .map(|(_, value)| value)
7704 .or_else(|| summary.split_once(" declaring ").map(|(_, value)| value))?;
7705 let declaration_clause = trim_summary_clause(after_marker);
7706 let names = strip_declaration_kind_prefix(declaration_clause)
7707 .split(',')
7708 .map(str::trim)
7709 .filter(|name| !name.is_empty())
7710 .take(3)
7711 .map(ToOwned::to_owned)
7712 .collect::<Vec<_>>();
7713 if names.is_empty() {
7714 None
7715 } else {
7716 Some(join_human_names(&names))
7717 }
7718}
7719
7720fn trim_summary_clause(value: &str) -> &str {
7722 value
7723 .split(" with imports ")
7724 .next()
7725 .unwrap_or(value)
7726 .split(" and depending on ")
7727 .next()
7728 .unwrap_or(value)
7729 .trim_end_matches('.')
7730 .trim()
7731}
7732
7733fn strip_declaration_kind_prefix(value: &str) -> &str {
7735 const PREFIXES: &[&str] = &[
7736 "types and functions ",
7737 "type and functions ",
7738 "types and function ",
7739 "type and function ",
7740 "manifest entries ",
7741 "functions ",
7742 "function ",
7743 "types ",
7744 "type ",
7745 "bindings ",
7746 "binding ",
7747 "values ",
7748 "value ",
7749 "symbols ",
7750 ];
7751 PREFIXES
7752 .iter()
7753 .find_map(|prefix| value.strip_prefix(prefix))
7754 .unwrap_or(value)
7755}
7756
7757fn join_human_names(names: &[String]) -> String {
7759 match names {
7760 [] => String::new(),
7761 [one] => one.clone(),
7762 [first, second] => format!("{first} and {second}"),
7763 [first, second, third, ..] => format!("{first}, {second}, and {third}"),
7764 }
7765}
7766
7767fn summary_between<'a>(summary: &'a str, start: &str, end: &str) -> Option<&'a str> {
7769 let after_start = summary.split_once(start)?.1;
7770 let value = after_start.split_once(end)?.0.trim();
7771 (!value.is_empty()).then_some(value)
7772}
7773
7774pub(crate) fn is_symbol_candidate(path: &str, language: Option<&str>) -> bool {
7776 let Some(language) = language else {
7777 let embedded_host = normalized_language_extension(Path::new(path))
7778 .and_then(|extension| detect_language_for_path(path, Some(&extension)))
7779 .and_then(|language| language_capability(&language))
7780 .is_some_and(|capability| capability.embedded_language.is_some());
7781 return embedded_host
7782 || path.ends_with("Cargo.toml")
7783 || path.ends_with("Cargo.lock")
7784 || Path::new(path)
7785 .extension()
7786 .and_then(|extension| extension.to_str())
7787 .is_some_and(|extension| {
7788 ["ps1", "psm1", "psd1"]
7789 .iter()
7790 .any(|expected| extension.eq_ignore_ascii_case(expected))
7791 });
7792 };
7793 language_capability(language).is_none_or(|capability| {
7794 capability.symbol_parser != SymbolParserOwner::Unavailable
7795 || capability.embedded_language.is_some()
7796 })
7797}
7798
7799fn is_symbol_candidate_for_admission(
7801 path: &str,
7802 language: Option<&str>,
7803 admit_optional_languages: bool,
7804) -> bool {
7805 if !admit_optional_languages
7806 && language
7807 .and_then(language_capability)
7808 .is_some_and(|capability| capability.optional_pack.is_some())
7809 {
7810 return false;
7811 }
7812 is_symbol_candidate(path, language)
7813}
7814
7815fn clear_skipped_symbol_index(
7817 store: &AtlasStore,
7818 path: &str,
7819 language: Option<&str>,
7820) -> Result<(), CliError> {
7821 if is_structural_summary_candidate(path, language) {
7822 store.clear_symbol_graph_for_path(path)?;
7823 } else {
7824 store.clear_source_index_for_path(path)?;
7825 }
7826 Ok(())
7827}
7828
7829pub(crate) fn validated_file_key(file: &Path) -> Result<String, CliError> {
7831 validated_repo_file_key(file).map_err(|source| CliError::InvalidInput(source.to_string()))
7832}
7833
7834pub(crate) fn normalized_folder_filter(folder: &str) -> Result<String, CliError> {
7836 let trimmed = folder.trim().trim_end_matches(['/', '\\']);
7837 if trimmed.is_empty() || trimmed == "." {
7838 return Ok(".".to_string());
7839 }
7840 validated_file_key(Path::new(trimmed)).map_err(|_error| {
7841 CliError::InvalidInput(format!(
7842 "folder filter {folder:?} must be a project-relative path"
7843 ))
7844 })
7845}
7846
7847pub(crate) fn validated_indexed_file_key(
7849 store: &AtlasStore,
7850 file: &Path,
7851) -> Result<String, CliError> {
7852 let file_key = validated_file_key(file)?;
7853 let indexed = store
7854 .load_node_by_path(&file_key)?
7855 .ok_or_else(|| CliError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
7856 if indexed.node.kind != NodeKind::File {
7857 return Err(CliError::InvalidInput(format!(
7858 "path {file_key:?} is not an indexed file"
7859 )));
7860 }
7861 Ok(file_key)
7862}
7863
7864pub(crate) fn indexed_project_root(store: &AtlasStore) -> Result<PathBuf, CliError> {
7866 store
7867 .project_root_identity()?
7868 .map(CanonicalProjectRoot::into_path)
7869 .ok_or_else(|| {
7870 CliError::InvalidInput(
7871 "indexed project root is missing; run projectatlas scan <project-root> first"
7872 .to_string(),
7873 )
7874 })
7875}
7876
7877pub(crate) fn indexed_native_path(store: &AtlasStore, file_key: &str) -> Result<PathBuf, CliError> {
7879 Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
7880}
7881
7882pub(crate) fn read_indexed_file_content(
7884 store: &AtlasStore,
7885 file_key: &str,
7886) -> Result<String, CliError> {
7887 let native = indexed_native_path(store, file_key)?;
7888 let indexed = store.load_node_by_path(file_key)?.ok_or_else(|| {
7889 CliError::InvalidInput(format!("indexed file {file_key:?} was not found"))
7890 })?;
7891 let project_root = lossless_project_root_display(&indexed_project_root(store)?);
7892 let metadata = match fs::metadata(&native) {
7893 Ok(metadata) => metadata,
7894 Err(source) if source.kind() == io::ErrorKind::NotFound => {
7895 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7896 project_root,
7897 worktree: None,
7898 status: IndexReadStatus::RefreshRequired,
7899 reason: IndexRefreshReason::PathsChanged,
7900 scope: IndexRefreshScope::Full,
7901 changed: 1,
7902 added: 0,
7903 removed: 1,
7904 modified: 0,
7905 sample_paths: vec![file_key.to_string()],
7906 })));
7907 }
7908 Err(source) => {
7909 return Err(CliError::VerificationIncomplete(Box::new(
7910 IndexVerificationIncomplete {
7911 project_root,
7912 worktree: None,
7913 status: IndexReadStatus::VerificationIncomplete,
7914 reason: IndexVerificationReason::SourceInspectionFailed,
7915 scope: IndexRefreshScope::Full,
7916 message: format!("failed to read '{}': {source}", native.display()),
7917 },
7918 )));
7919 }
7920 };
7921 if indexed
7922 .node
7923 .size_bytes
7924 .is_some_and(|indexed_bytes| indexed_bytes != metadata.len())
7925 {
7926 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7927 project_root,
7928 worktree: None,
7929 status: IndexReadStatus::RefreshRequired,
7930 reason: IndexRefreshReason::SourceChanged,
7931 scope: IndexRefreshScope::Full,
7932 changed: 1,
7933 added: 0,
7934 removed: 0,
7935 modified: 1,
7936 sample_paths: vec![file_key.to_string()],
7937 })));
7938 }
7939 if metadata.len() > MAX_INDEXED_NAVIGATION_SOURCE_BYTES {
7940 return Err(CliError::VerificationIncomplete(Box::new(
7941 IndexVerificationIncomplete {
7942 project_root,
7943 worktree: None,
7944 status: IndexReadStatus::VerificationIncomplete,
7945 reason: IndexVerificationReason::SourceTooLarge,
7946 scope: IndexRefreshScope::Full,
7947 message: format!(
7948 "indexed file {file_key:?} contains {} bytes; bounded navigation reads admit at most {MAX_INDEXED_NAVIGATION_SOURCE_BYTES} bytes",
7949 metadata.len()
7950 ),
7951 },
7952 )));
7953 }
7954 let file = fs::File::open(&native).map_err(|source| {
7955 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7956 project_root: project_root.clone(),
7957 worktree: None,
7958 status: IndexReadStatus::VerificationIncomplete,
7959 reason: IndexVerificationReason::SourceInspectionFailed,
7960 scope: IndexRefreshScope::Full,
7961 message: format!("failed to open '{}': {source}", native.display()),
7962 }))
7963 })?;
7964 let mut bytes = Vec::with_capacity(metadata.len() as usize);
7965 file.take(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)
7966 .read_to_end(&mut bytes)
7967 .map_err(|source| {
7968 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
7969 project_root: project_root.clone(),
7970 worktree: None,
7971 status: IndexReadStatus::VerificationIncomplete,
7972 reason: IndexVerificationReason::SourceInspectionFailed,
7973 scope: IndexRefreshScope::Full,
7974 message: format!("failed to read '{}': {source}", native.display()),
7975 }))
7976 })?;
7977 if bytes.len() as u64 != metadata.len()
7978 || bytes.len() as u64 > MAX_INDEXED_NAVIGATION_SOURCE_BYTES
7979 {
7980 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7981 project_root,
7982 worktree: None,
7983 status: IndexReadStatus::RefreshRequired,
7984 reason: IndexRefreshReason::SourceChanged,
7985 scope: IndexRefreshScope::Full,
7986 changed: 1,
7987 added: 0,
7988 removed: 0,
7989 modified: 1,
7990 sample_paths: vec![file_key.to_string()],
7991 })));
7992 }
7993 let current_hash = blake3::hash(&bytes).to_hex().to_string();
7994 if indexed.node.content_hash.as_deref() != Some(current_hash.as_str()) {
7995 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
7996 project_root,
7997 worktree: None,
7998 status: IndexReadStatus::RefreshRequired,
7999 reason: IndexRefreshReason::SourceChanged,
8000 scope: IndexRefreshScope::Full,
8001 changed: 1,
8002 added: 0,
8003 removed: 0,
8004 modified: 1,
8005 sample_paths: vec![file_key.to_string()],
8006 })));
8007 }
8008 if document_format_for_path(file_key, indexed.node.language.as_deref()).is_some() {
8009 return extract_document_text_controlled(
8010 &bytes,
8011 file_key,
8012 indexed.node.language.as_deref(),
8013 &standalone_index_work_control(),
8014 )
8015 .map(|facts| facts.text)
8016 .map_err(|error| document_navigation_error(file_key, error));
8017 }
8018 String::from_utf8(bytes).map_err(|source| {
8019 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
8020 project_root,
8021 worktree: None,
8022 status: IndexReadStatus::VerificationIncomplete,
8023 reason: IndexVerificationReason::SourceInspectionFailed,
8024 scope: IndexRefreshScope::Full,
8025 message: format!("indexed file {file_key:?} is not valid UTF-8: {source}"),
8026 }))
8027 })
8028}
8029
8030pub(crate) fn run_watch_loop(
8032 store: &mut AtlasStore,
8033 plan: &ScanRuntimePlan,
8034 once: bool,
8035 poll_seconds: u64,
8036 max_cycles: usize,
8037 symbol_options: &SymbolBuildOptions,
8038) -> Result<WatchReport, CliError> {
8039 if once {
8040 return run_single_watch_refresh(store, plan, symbol_options);
8041 }
8042 run_watch_with_polling_fallback(
8043 store,
8044 plan,
8045 poll_seconds,
8046 max_cycles,
8047 symbol_options,
8048 |store| run_notify_watch_loop(store, plan, poll_seconds, max_cycles, symbol_options),
8049 )
8050}
8051
8052fn run_watch_with_polling_fallback<F>(
8054 store: &mut AtlasStore,
8055 plan: &ScanRuntimePlan,
8056 poll_seconds: u64,
8057 max_cycles: usize,
8058 symbol_options: &SymbolBuildOptions,
8059 run_notify: F,
8060) -> Result<WatchReport, CliError>
8061where
8062 F: FnOnce(&mut AtlasStore) -> Result<WatchReport, CliError>,
8063{
8064 match run_notify(store) {
8065 Ok(report) => Ok(report),
8066 Err(error @ CliError::RefreshRequired(_)) => Err(error),
8067 Err(error) => run_polling_watch_loop(
8068 store,
8069 plan,
8070 poll_seconds,
8071 max_cycles,
8072 symbol_options,
8073 Some(error.to_string()),
8074 ),
8075 }
8076}
8077
8078pub(crate) fn run_single_watch_refresh(
8080 store: &mut AtlasStore,
8081 plan: &ScanRuntimePlan,
8082 symbol_options: &SymbolBuildOptions,
8083) -> Result<WatchReport, CliError> {
8084 let control = index_work_control(symbol_options);
8085 run_single_watch_refresh_controlled(store, plan, symbol_options, &control)
8086}
8087
8088pub(crate) fn run_single_watch_refresh_controlled(
8090 store: &mut AtlasStore,
8091 plan: &ScanRuntimePlan,
8092 symbol_options: &SymbolBuildOptions,
8093 control: &IndexWorkControl,
8094) -> Result<WatchReport, CliError> {
8095 let bounded_control = bounded_index_work_control(control);
8096 let control = &bounded_control;
8097 control.check(IndexWorkStage::RepositoryTraversal)?;
8098 let current_plan = plan.reload_controlled(control)?;
8099 let last_refresh = refresh_index_controlled(store, ¤t_plan, symbol_options, control)?;
8100 Ok(WatchReport::from_refresh(last_refresh))
8101}
8102
8103pub(crate) fn run_notify_watch_loop(
8105 store: &mut AtlasStore,
8106 plan: &ScanRuntimePlan,
8107 poll_seconds: u64,
8108 max_cycles: usize,
8109 symbol_options: &SymbolBuildOptions,
8110) -> Result<WatchReport, CliError> {
8111 let mut current_plan = plan.reload()?;
8112 let watch_root = current_plan
8113 .root
8114 .canonicalize()
8115 .map_err(|source| CliError::Io {
8116 path: current_plan.root.clone(),
8117 source,
8118 })?;
8119 let (sender, receiver) = mpsc::sync_channel(WATCH_EVENT_QUEUE_CAPACITY);
8120 let continuity_lost = Arc::new(AtomicBool::new(false));
8121 let callback_continuity_lost = Arc::clone(&continuity_lost);
8122 let mut watcher = RecommendedWatcher::new(
8123 move |result: notify::Result<Event>| {
8124 match sender.try_send(result) {
8125 Ok(()) => {}
8126 Err(TrySendError::Full(_result)) => {
8127 callback_continuity_lost.store(true, Ordering::Release);
8128 }
8129 Err(TrySendError::Disconnected(_result)) => {
8130 }
8132 }
8133 },
8134 Config::default(),
8135 )
8136 .map_err(|source| CliError::Watcher(source.to_string()))?;
8137 watcher
8138 .watch(&watch_root, RecursiveMode::Recursive)
8139 .map_err(|source| CliError::Watcher(source.to_string()))?;
8140 let debounce = Duration::from_secs(poll_seconds.max(1));
8141 let mut cycles = 0;
8142 let mut last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
8143 cycles += 1;
8144 while max_cycles == 0 || cycles < max_cycles {
8145 let changes = wait_for_index_event_with_continuity(
8146 &receiver,
8147 &watch_root,
8148 debounce,
8149 ¤t_plan.scan_options,
8150 &continuity_lost,
8151 )?;
8152 if changes.has_changes() {
8153 current_plan = plan.reload()?;
8154 last_refresh =
8155 refresh_index_for_changes(store, ¤t_plan, &changes, symbol_options)?;
8156 cycles += 1;
8157 }
8158 }
8159 Ok(WatchReport {
8160 mode: WATCH_MODE_NOTIFY.to_string(),
8161 cycles,
8162 once: false,
8163 fallback_reason: None,
8164 text_index: last_refresh.text_index,
8165 structural_summaries: last_refresh.structural_summaries,
8166 last_symbols: last_refresh.symbols,
8167 })
8168}
8169
8170fn wait_for_index_event_with_continuity(
8172 receiver: &mpsc::Receiver<notify::Result<Event>>,
8173 root: &Path,
8174 debounce: Duration,
8175 scan_options: &ScanOptions,
8176 continuity_lost: &AtomicBool,
8177) -> Result<WatchChangeSet, CliError> {
8178 let mut changes = notify_result_changes(
8179 root,
8180 scan_options,
8181 receiver.recv().map_err(|source| {
8182 CliError::Watcher(format!("watch event channel disconnected: {source}"))
8183 })?,
8184 )?;
8185 loop {
8186 match receiver.recv_timeout(debounce) {
8187 Ok(result) => {
8188 changes.merge(notify_result_changes(root, scan_options, result)?);
8189 }
8190 Err(RecvTimeoutError::Timeout) => break,
8191 Err(RecvTimeoutError::Disconnected) => {
8192 return Err(CliError::Watcher(
8193 "watch event channel disconnected".to_string(),
8194 ));
8195 }
8196 }
8197 }
8198 if continuity_lost.swap(false, Ordering::AcqRel) {
8199 changes.requires_full_scan = true;
8200 }
8201 Ok(changes)
8202}
8203
8204pub(crate) fn notify_result_changes(
8206 root: &Path,
8207 scan_options: &ScanOptions,
8208 result: notify::Result<Event>,
8209) -> Result<WatchChangeSet, CliError> {
8210 let event = result.map_err(|source| CliError::Watcher(source.to_string()))?;
8211 Ok(notify_event_changes(root, scan_options, &event))
8212}
8213
8214pub(crate) fn notify_event_changes(
8216 root: &Path,
8217 scan_options: &ScanOptions,
8218 event: &Event,
8219) -> WatchChangeSet {
8220 if !event_kind_affects_index(event.kind) {
8221 return WatchChangeSet::default();
8222 }
8223 let mut changes = WatchChangeSet {
8224 requires_full_scan: event.need_rescan(),
8225 paths: HashSet::new(),
8226 document_paths: HashSet::new(),
8227 };
8228 for path in &event.paths {
8229 let candidate = absolute_watch_path(root, path);
8230 if safe_watch_relative_path(root, &candidate).is_some() {
8231 changes.document_paths.insert(candidate.clone());
8232 }
8233 if watch_path_requires_full_scan(root, &candidate) {
8234 changes.requires_full_scan = true;
8235 changes.paths.insert(candidate);
8236 continue;
8237 }
8238 let Some(index_path) = normalized_watch_index_path(root, path, scan_options) else {
8239 continue;
8240 };
8241 if matches!(
8242 event.kind,
8243 EventKind::Modify(notify::event::ModifyKind::Name(_))
8244 ) || watch_path_requires_full_scan(root, &index_path)
8245 {
8246 changes.requires_full_scan = true;
8247 }
8248 changes.paths.insert(index_path);
8249 }
8250 changes
8251}
8252
8253pub(crate) fn event_kind_affects_index(kind: EventKind) -> bool {
8255 !matches!(kind, EventKind::Access(_))
8256}
8257
8258#[cfg(test)]
8260pub(crate) fn watch_path_affects_index(
8261 root: &Path,
8262 path: &Path,
8263 scan_options: &ScanOptions,
8264) -> bool {
8265 normalized_watch_index_path(root, path, scan_options).is_some()
8266}
8267
8268fn normalized_watch_index_path(
8270 root: &Path,
8271 path: &Path,
8272 scan_options: &ScanOptions,
8273) -> Option<PathBuf> {
8274 let candidate = absolute_watch_path(root, path);
8275 let relative = safe_watch_relative_path(root, &candidate)?;
8276 if relative == "." {
8277 return Some(root.to_path_buf());
8278 }
8279 let policy_path = if candidate.strip_prefix(root).is_ok() {
8280 candidate.clone()
8281 } else {
8282 match candidate.try_exists() {
8283 Ok(true) => candidate.clone(),
8284 Ok(false) => root.join(repo_path_to_native(&relative)),
8285 Err(_) => return None,
8286 }
8287 };
8288 let Ok(gitignore_ignored) = gitignore_excludes_path(root, &policy_path) else {
8290 return None;
8291 };
8292 if gitignore_ignored {
8293 return None;
8294 }
8295 if relative.split('/').any(|component| component == ".purpose")
8296 || scan_options.excludes_relative_path(&relative)
8297 {
8298 return None;
8299 }
8300 Some(candidate)
8301}
8302
8303fn safe_watch_relative_path(root: &Path, candidate: &Path) -> Option<String> {
8305 let relative = normalize_repo_path(root, candidate)
8306 .ok()
8307 .or_else(|| native_display_relative_path(root, candidate))?;
8308 valid_watch_relative_path(relative)
8309}
8310
8311fn native_display_relative_path(root: &Path, candidate: &Path) -> Option<String> {
8313 let root = normalize_native_path_display_str(root.to_str()?);
8314 let candidate = normalize_native_path_display_str(candidate.to_str()?);
8315 let root = if root == "/" {
8316 root.as_str()
8317 } else {
8318 root.trim_end_matches('/')
8319 };
8320 if candidate == root || cfg!(windows) && candidate.eq_ignore_ascii_case(root) {
8321 return Some(".".to_string());
8322 }
8323 let prefix = if root == "/" {
8324 "/".to_string()
8325 } else {
8326 format!("{root}/")
8327 };
8328 if let Some(relative) = candidate.strip_prefix(&prefix) {
8329 return Some(relative.to_string());
8330 }
8331 #[cfg(windows)]
8332 {
8333 let prefix_candidate = candidate.get(..prefix.len())?;
8334 if prefix_candidate.eq_ignore_ascii_case(&prefix) {
8335 return candidate.get(prefix.len()..).map(ToOwned::to_owned);
8336 }
8337 }
8338 None
8339}
8340
8341fn valid_watch_relative_path(relative: String) -> Option<String> {
8343 if relative == "." {
8344 return Some(relative);
8345 }
8346 if relative
8347 .split('/')
8348 .any(|component| component.is_empty() || component == "." || component == "..")
8349 {
8350 return None;
8351 }
8352 Some(relative)
8353}
8354
8355pub(crate) fn absolute_watch_path(root: &Path, path: &Path) -> PathBuf {
8357 if path.is_absolute() {
8358 path.to_path_buf()
8359 } else {
8360 root.join(path)
8361 }
8362}
8363
8364pub(crate) fn watch_path_requires_full_scan(root: &Path, path: &Path) -> bool {
8366 let Some(relative) = safe_watch_relative_path(root, path) else {
8367 return false;
8368 };
8369 if relative == "." {
8370 return true;
8371 }
8372 path.is_dir()
8373 || matches!(relative.rsplit('/').next(), Some(".gitignore" | ".ignore"))
8374 || relative == ".git"
8375 || relative.ends_with("/.git")
8376 || relative == ".git/info/exclude"
8377 || relative.ends_with("/.git/info/exclude")
8378 || matches!(
8379 relative.rsplit('/').next(),
8380 Some("tsconfig.json" | "jsconfig.json")
8381 )
8382 || index_policy_path(relative.as_str())
8383}
8384
8385fn index_policy_path(relative: &str) -> bool {
8387 CORE_INDEX_POLICY_PATHS.contains(&relative)
8388 || cfg!(feature = "optional-parser-supervisor") && {
8389 #[cfg(feature = "optional-parser-supervisor")]
8390 {
8391 relative == OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH
8392 }
8393 #[cfg(not(feature = "optional-parser-supervisor"))]
8394 {
8395 false
8396 }
8397 }
8398}
8399
8400pub(crate) fn run_polling_watch_loop(
8402 store: &mut AtlasStore,
8403 plan: &ScanRuntimePlan,
8404 poll_seconds: u64,
8405 max_cycles: usize,
8406 symbol_options: &SymbolBuildOptions,
8407 fallback_reason: Option<String>,
8408) -> Result<WatchReport, CliError> {
8409 let mut cycles = 0;
8410 let mut current_plan = plan.reload()?;
8411 let mut last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
8412 cycles += 1;
8413 while max_cycles == 0 || cycles < max_cycles {
8414 thread::sleep(Duration::from_secs(poll_seconds.max(1)));
8415 current_plan = plan.reload()?;
8416 last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
8417 cycles += 1;
8418 }
8419 Ok(WatchReport {
8420 mode: WATCH_MODE_POLLING.to_string(),
8421 cycles,
8422 once: false,
8423 fallback_reason,
8424 text_index: last_refresh.text_index,
8425 structural_summaries: last_refresh.structural_summaries,
8426 last_symbols: last_refresh.symbols,
8427 })
8428}
8429
8430pub(crate) struct IndexRefreshReport {
8432 pub(crate) text_index: TextIndexReport,
8434 pub(crate) structural_summaries: StructuralSummaryReport,
8436 symbols: SymbolBuildReport,
8438}
8439
8440pub(crate) fn refresh_index(
8442 store: &mut AtlasStore,
8443 plan: &ScanRuntimePlan,
8444 symbol_options: &SymbolBuildOptions,
8445) -> Result<IndexRefreshReport, CliError> {
8446 let control = index_work_control(symbol_options);
8447 refresh_index_controlled(store, plan, symbol_options, &control)
8448}
8449
8450pub(crate) fn refresh_index_controlled(
8452 store: &mut AtlasStore,
8453 plan: &ScanRuntimePlan,
8454 symbol_options: &SymbolBuildOptions,
8455 control: &IndexWorkControl,
8456) -> Result<IndexRefreshReport, CliError> {
8457 let bounded_control = bounded_index_work_control(control);
8458 let control = &bounded_control;
8459 let reuse_unchanged_symbols = publication_contract_matches(store, plan)?;
8460 if reuse_unchanged_symbols {
8461 let assessment =
8462 detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?;
8463 return refresh_index_from_assessment_controlled(
8464 store,
8465 plan,
8466 symbol_options,
8467 assessment,
8468 control,
8469 );
8470 }
8471 refresh_full_index_controlled(
8472 store,
8473 plan,
8474 symbol_options,
8475 reuse_unchanged_symbols,
8476 control,
8477 )
8478}
8479
8480pub(crate) fn refresh_index_from_assessment_controlled(
8482 store: &mut AtlasStore,
8483 plan: &ScanRuntimePlan,
8484 symbol_options: &SymbolBuildOptions,
8485 assessment: IndexFreshnessAssessment,
8486 control: &IndexWorkControl,
8487) -> Result<IndexRefreshReport, CliError> {
8488 verify_captured_source_identity(store, plan, &assessment.source)?;
8489 match assessment.delta {
8490 None => {
8491 graph_projection::cleanup_abandoned_repository_graph_staging(
8492 store, &plan.root, control,
8493 )?;
8494 return Ok(empty_index_refresh_report(plan.text_options));
8495 }
8496 Some(delta) if delta.paths_complete && !delta.requires_full_scan => {
8497 let changes = WatchChangeSet {
8498 requires_full_scan: false,
8499 document_paths: delta.paths.clone(),
8500 paths: delta.paths,
8501 };
8502 match refresh_index_for_changes_from_source_controlled(
8503 store,
8504 plan,
8505 &changes,
8506 symbol_options,
8507 Some(assessment.source),
8508 control,
8509 ) {
8510 Ok(report) => return Ok(report),
8511 Err(error) if incremental_refresh_needs_full(&error) => {}
8512 Err(error) => return Err(error),
8513 }
8514 }
8515 Some(_) => {
8516 return refresh_full_index_from_source_controlled(
8517 store,
8518 plan,
8519 symbol_options,
8520 true,
8521 Some(assessment.source),
8522 control,
8523 );
8524 }
8525 }
8526 refresh_full_index_controlled(store, plan, symbol_options, true, control)
8527}
8528
8529fn refresh_full_index_controlled(
8531 store: &mut AtlasStore,
8532 plan: &ScanRuntimePlan,
8533 symbol_options: &SymbolBuildOptions,
8534 reuse_unchanged_symbols: bool,
8535 control: &IndexWorkControl,
8536) -> Result<IndexRefreshReport, CliError> {
8537 refresh_full_index_from_source_controlled(
8538 store,
8539 plan,
8540 symbol_options,
8541 reuse_unchanged_symbols,
8542 None,
8543 control,
8544 )
8545}
8546
8547fn refresh_full_index_from_source_controlled(
8549 store: &mut AtlasStore,
8550 plan: &ScanRuntimePlan,
8551 symbol_options: &SymbolBuildOptions,
8552 reuse_unchanged_symbols: bool,
8553 captured: Option<CapturedSourceState>,
8554 control: &IndexWorkControl,
8555) -> Result<IndexRefreshReport, CliError> {
8556 control.check(IndexWorkStage::Publication)?;
8557 store.probe_index_publication_writer()?;
8558 let batch = stage_full_index_publication_from_source(
8559 store,
8560 plan,
8561 symbol_options,
8562 reuse_unchanged_symbols,
8563 false,
8564 captured,
8565 control,
8566 )?;
8567 revalidate_staged_publication_inputs_controlled(
8568 plan,
8569 batch.nodes.expected_nodes(),
8570 None,
8571 control,
8572 )?;
8573 if staged_full_refresh_is_unchanged(store, &batch)? {
8574 return Ok(empty_index_refresh_report(plan.text_options));
8575 }
8576 let outcome = publish_index_batch(store, batch, control)?;
8577 Ok(IndexRefreshReport {
8578 text_index: outcome.text_index,
8579 structural_summaries: outcome.structural_summaries,
8580 symbols: outcome.symbols,
8581 })
8582}
8583
8584fn staged_full_refresh_is_unchanged(
8586 store: &AtlasStore,
8587 batch: &IndexPublicationBatch,
8588) -> Result<bool, CliError> {
8589 let NodePublicationBatch::Full { nodes: expected } = &batch.nodes else {
8590 return Ok(false);
8591 };
8592 if batch.purpose_import.is_some() {
8593 return Ok(false);
8594 }
8595 let Some(publication) = store.index_publication()? else {
8596 return Ok(false);
8597 };
8598 if publication.state != IndexPublicationState::Complete
8599 || publication.generation != batch.base_generation
8600 || publication.contract_fingerprint.as_deref() != Some(batch.contract_fingerprint.as_str())
8601 {
8602 return Ok(false);
8603 }
8604 let current = store
8605 .load_nodes()?
8606 .into_iter()
8607 .map(|indexed| indexed.node)
8608 .collect::<Vec<_>>();
8609 Ok(current.len() == expected.len()
8610 && current
8611 .iter()
8612 .zip(expected)
8613 .all(|(current, expected)| same_indexed_source(current, expected)))
8614}
8615
8616fn empty_index_refresh_report(text_options: TextIndexOptions) -> IndexRefreshReport {
8618 IndexRefreshReport {
8619 text_index: empty_text_index_report(text_options),
8620 structural_summaries: StructuralSummaryReport::default(),
8621 symbols: empty_symbol_build_report(),
8622 }
8623}
8624
8625pub(crate) fn refresh_index_for_changes(
8627 store: &mut AtlasStore,
8628 plan: &ScanRuntimePlan,
8629 changes: &WatchChangeSet,
8630 symbol_options: &SymbolBuildOptions,
8631) -> Result<IndexRefreshReport, CliError> {
8632 let control = index_work_control(symbol_options);
8633 match refresh_index_for_changes_controlled(store, plan, changes, symbol_options, &control) {
8634 Err(error) if incremental_refresh_needs_full(&error) => {
8635 let reuse = publication_contract_matches(store, plan)?;
8636 refresh_full_index_controlled(store, plan, symbol_options, reuse, &control)
8637 }
8638 result => result,
8639 }
8640}
8641
8642fn incremental_refresh_needs_full(error: &CliError) -> bool {
8644 matches!(error,
8645 CliError::RefreshRequired(report)
8646 if report.reason == IndexRefreshReason::DependencyClosureLimit
8647 ) || matches!(
8648 error,
8649 CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8650 resource: IndexWorkResource::SourceBytes | IndexWorkResource::Entries,
8651 ..
8652 })
8653 )
8654}
8655
8656pub(crate) fn refresh_index_for_changes_controlled(
8658 store: &mut AtlasStore,
8659 plan: &ScanRuntimePlan,
8660 changes: &WatchChangeSet,
8661 symbol_options: &SymbolBuildOptions,
8662 control: &IndexWorkControl,
8663) -> Result<IndexRefreshReport, CliError> {
8664 refresh_index_for_changes_from_source_controlled(
8665 store,
8666 plan,
8667 changes,
8668 symbol_options,
8669 None,
8670 control,
8671 )
8672}
8673
8674fn refresh_index_for_changes_from_source_controlled(
8676 store: &mut AtlasStore,
8677 plan: &ScanRuntimePlan,
8678 changes: &WatchChangeSet,
8679 symbol_options: &SymbolBuildOptions,
8680 captured: Option<CapturedSourceState>,
8681 control: &IndexWorkControl,
8682) -> Result<IndexRefreshReport, CliError> {
8683 let bounded_control = bounded_index_work_control(control);
8684 let control = &bounded_control;
8685 control.check(IndexWorkStage::RepositoryTraversal)?;
8686 if changes.requires_full_scan || !publication_contract_matches(store, plan)? {
8687 return refresh_index_controlled(store, plan, symbol_options, control);
8688 }
8689 let changed_event_path_count = changes.paths.union(&changes.document_paths).count();
8690 if changed_event_path_count > MAX_INCREMENTAL_CHANGED_PATHS {
8691 return Err(IndexWorkFailure::resource_limit(
8692 IndexWorkStage::RepositoryTraversal,
8693 IndexWorkResource::Entries,
8694 MAX_INCREMENTAL_CHANGED_PATHS as u64,
8695 changed_event_path_count as u64,
8696 )
8697 .into());
8698 }
8699 let root = &plan.root;
8700 let base_generation = publication_base_generation(store)?;
8701 if let Some(source) = &captured {
8702 verify_captured_source_identity(store, plan, source)?;
8703 }
8704 let mut baseline_nodes = Vec::new();
8705 if captured.is_none() {
8706 store.visit_source_nodes_controlled(control, |node| {
8707 baseline_nodes.push(node);
8708 Ok(())
8709 })?;
8710 }
8711 let mut nodes = Vec::new();
8712 let mut absent_paths = Vec::new();
8713 let mut source_bytes = 0_u64;
8714 let scan_policy = RootScanPolicy::discover(root, &plan.scan_options, control)
8715 .map_err(|source| source_inspection_error(root, source))?;
8716 let mut direct_document_paths = changes
8717 .paths
8718 .union(&changes.document_paths)
8719 .map(|path| normalized_deleted_path(root, path))
8720 .collect::<Result<Vec<_>, _>>()?
8721 .into_iter()
8722 .flatten()
8723 .collect::<BTreeSet<_>>();
8724 for path in sorted_watch_paths(&changes.paths) {
8725 control.check(IndexWorkStage::RepositoryTraversal)?;
8726 let node = if let Some(source) = &captured {
8727 normalized_deleted_path(root, &path)?.and_then(|key| {
8728 source
8729 .nodes
8730 .binary_search_by(|node| node.path.cmp(&key))
8731 .ok()
8732 .map(|index| source.nodes[index].clone())
8733 })
8734 } else {
8735 scan_incremental_path_controlled(
8736 root,
8737 &scan_policy,
8738 &path,
8739 MAX_INCREMENTAL_SOURCE_BYTES.saturating_sub(source_bytes),
8740 control,
8741 )?
8742 };
8743 if let Some(node) = node {
8744 source_bytes = source_bytes
8745 .checked_add(node.size_bytes.unwrap_or_default())
8746 .ok_or_else(|| {
8747 IndexWorkFailure::resource_limit(
8748 IndexWorkStage::SourceMetadata,
8749 IndexWorkResource::SourceBytes,
8750 MAX_INCREMENTAL_SOURCE_BYTES,
8751 u64::MAX,
8752 )
8753 })?;
8754 if source_bytes > MAX_INCREMENTAL_SOURCE_BYTES {
8755 return Err(IndexWorkFailure::resource_limit(
8756 IndexWorkStage::SourceMetadata,
8757 IndexWorkResource::SourceBytes,
8758 MAX_INCREMENTAL_SOURCE_BYTES,
8759 source_bytes,
8760 )
8761 .into());
8762 }
8763 nodes.push(node);
8764 } else if let Some(key) = normalized_deleted_path(root, &path)? {
8765 absent_paths.push(key);
8766 }
8767 }
8768 absent_paths.sort();
8769 absent_paths.dedup();
8770 let sorted_candidate_paths = nodes
8771 .iter()
8772 .map(|node| node.path.clone())
8773 .chain(absent_paths.iter().cloned())
8774 .chain(direct_document_paths.iter().cloned())
8775 .collect::<BTreeSet<_>>()
8776 .into_iter()
8777 .collect::<Vec<_>>();
8778 let existing_nodes = store
8779 .load_nodes_by_paths_controlled(&sorted_candidate_paths, Some(control))?
8780 .into_iter()
8781 .map(|indexed| (indexed.node.path.clone(), indexed.node))
8782 .collect::<HashMap<_, _>>();
8783 if absent_paths.iter().any(|path| {
8784 existing_nodes
8785 .get(path)
8786 .is_some_and(|node| node.kind == NodeKind::Folder)
8787 }) {
8788 return refresh_index_controlled(store, plan, symbol_options, control);
8789 }
8790 graph_projection::cleanup_abandoned_repository_graph_staging(store, root, control)?;
8791 direct_document_paths.extend(
8792 nodes
8793 .iter()
8794 .map(|node| node.path.clone())
8795 .chain(absent_paths.iter().cloned()),
8796 );
8797 nodes.retain(|node| {
8798 existing_nodes
8799 .get(&node.path)
8800 .is_none_or(|indexed| !same_indexed_source(node, indexed))
8801 });
8802 absent_paths.retain(|path| existing_nodes.contains_key(path));
8803 let changed_paths = nodes
8804 .iter()
8805 .map(|node| node.path.clone())
8806 .chain(absent_paths.iter().cloned())
8807 .collect::<HashSet<_>>();
8808 direct_document_paths
8809 .retain(|path| changed_paths.contains(path) || !existing_nodes.contains_key(path));
8810 let expected_nodes = match captured {
8811 Some(source) => source.nodes,
8812 None => expected_nodes_after_incremental(baseline_nodes, &nodes, &absent_paths),
8813 };
8814 if nodes.is_empty() && absent_paths.is_empty() && direct_document_paths.is_empty() {
8815 revalidate_staged_publication_inputs_controlled(plan, &expected_nodes, None, control)?;
8816 return Ok(empty_index_refresh_report(plan.text_options));
8817 }
8818 control.check(IndexWorkStage::Publication)?;
8819 store.probe_index_publication_writer()?;
8820 drop(existing_nodes);
8821 let previous_hashes = indexed_file_hashes_for_paths(store, &changed_paths)?;
8822 let mut text_paths = changed_paths.iter().cloned().collect::<Vec<_>>();
8823 text_paths.sort();
8824 let text =
8825 stage_text_index_for_changed_paths_controlled(root, &nodes, plan.text_options, control)?;
8826 let content_classifications = stage_file_content_classifications(&nodes, &text.rows);
8827 let protected_purpose_paths = protected_purpose_paths(&nodes, None);
8828 let target_paths = nodes
8829 .iter()
8830 .filter(|node| node.kind == NodeKind::File)
8831 .map(|node| node.path.clone())
8832 .collect::<HashSet<_>>();
8833 let contract_fingerprint = plan.publication_contract_fingerprint();
8834 let retained_before_symbols = staged_publication_identity_bytes(root, &contract_fingerprint)
8835 .saturating_add(staged_string_bytes(&text_paths))
8836 .saturating_add(staged_string_bytes(&absent_paths))
8837 .saturating_add(staged_node_bytes(&expected_nodes))
8838 .saturating_add(staged_node_bytes(&nodes))
8839 .saturating_add(staged_text_bytes(&text))
8840 .saturating_add(staged_file_content_classification_bytes(
8841 &content_classifications,
8842 ));
8843 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
8844 let mut symbols = stage_symbols_for_nodes_with_limits(
8845 store,
8846 root,
8847 #[cfg(feature = "optional-parser-supervisor")]
8848 &plan.optional_parser_selection,
8849 &nodes,
8850 symbol_options,
8851 Some(&previous_hashes),
8852 Some(&target_paths),
8853 &text.rows,
8854 &protected_purpose_paths,
8855 control,
8856 symbol_limits,
8857 )?;
8858 let graph = graph_projection::stage_incremental_repository_graph_with_limit(
8859 store,
8860 root,
8861 base_generation,
8862 &expected_nodes,
8863 &direct_document_paths.into_iter().collect::<Vec<_>>(),
8864 &scan_policy,
8865 &symbols,
8866 control,
8867 MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_before_symbols),
8868 )?;
8869 graph.account_source_recovery(&mut symbols.report)?;
8870 let structural_summaries = stage_structural_summaries_for_nodes_controlled(
8871 store,
8872 &nodes,
8873 &text.rows,
8874 Some(&symbols),
8875 &protected_purpose_paths,
8876 symbol_options.effective_workers(),
8877 control,
8878 )?;
8879 enforce_publication_staging_budget(
8880 retained_before_symbols
8881 .saturating_add(symbols.retained_bytes)
8882 .saturating_add(graph.retained_bytes())
8883 .saturating_add(structural_summaries.retained_bytes),
8884 )?;
8885 let batch = IndexPublicationBatch {
8886 base_generation,
8887 contract_fingerprint,
8888 root: root.clone(),
8889 nodes: NodePublicationBatch::Incremental {
8890 nodes,
8891 absent_paths,
8892 expected_nodes,
8893 },
8894 purpose_import: None,
8895 text_paths,
8896 text,
8897 content_classifications,
8898 symbols,
8899 graph,
8900 structural_summaries,
8901 };
8902 revalidate_staged_publication_inputs_controlled(
8903 plan,
8904 batch.nodes.expected_nodes(),
8905 None,
8906 control,
8907 )?;
8908 let outcome = publish_index_batch(store, batch, control)?;
8909 Ok(IndexRefreshReport {
8910 text_index: outcome.text_index,
8911 structural_summaries: outcome.structural_summaries,
8912 symbols: outcome.symbols,
8913 })
8914}
8915
8916fn verify_captured_source_identity(
8918 store: &AtlasStore,
8919 plan: &ScanRuntimePlan,
8920 source: &CapturedSourceState,
8921) -> Result<(), CliError> {
8922 if store.captured_project_binding()?.project_instance_id != source.project_instance_id {
8923 return Err(CliError::InvalidInput(
8924 "captured source belongs to a different project database".to_string(),
8925 ));
8926 }
8927 if source.root != plan.root
8928 || source.contract_fingerprint != plan.publication_contract_fingerprint()
8929 {
8930 return Err(CliError::RefreshRequired(Box::new(
8931 index_policy_refresh_required(&plan.root),
8932 )));
8933 }
8934 let generation = verify_index_publication(store, plan)?;
8935 if source.generation != generation {
8936 return Err(projectatlas_db::DbError::PublicationBaseGenerationChanged {
8937 expected: source.generation,
8938 found: generation,
8939 }
8940 .into());
8941 }
8942 Ok(())
8943}
8944
8945fn scan_incremental_path_controlled(
8947 root: &Path,
8948 policy: &RootScanPolicy,
8949 path: &Path,
8950 remaining_source_bytes: u64,
8951 control: &IndexWorkControl,
8952) -> Result<Option<Node>, CliError> {
8953 match path.try_exists() {
8954 Ok(true) => scan_path_with_policy_controlled(
8955 policy,
8956 path,
8957 ScanLimits::new(1, remaining_source_bytes, 1),
8958 control,
8959 )
8960 .map_err(|source| source_inspection_error(root, source)),
8961 Ok(false) => Ok(None),
8962 Err(source) => Err(CliError::VerificationIncomplete(Box::new(
8963 IndexVerificationIncomplete {
8964 project_root: lossless_project_root_display(root),
8965 worktree: None,
8966 status: IndexReadStatus::VerificationIncomplete,
8967 reason: IndexVerificationReason::SourceInspectionFailed,
8968 scope: IndexRefreshScope::Full,
8969 message: format!("failed to inspect '{}': {source}", path.display()),
8970 },
8971 ))),
8972 }
8973}
8974
8975pub(crate) fn seed_builtin_projectatlas_purposes(
8977 store: &AtlasStore,
8978 nodes: &[Node],
8979) -> Result<(), CliError> {
8980 let indexed_paths = nodes
8981 .iter()
8982 .map(|node| node.path.as_str())
8983 .collect::<HashSet<_>>();
8984 for (path, purpose) in BUILTIN_PROJECTATLAS_PURPOSES {
8985 if !indexed_paths.contains(path) {
8986 continue;
8987 }
8988 let Some(indexed) = store.load_node_by_path(path)? else {
8989 continue;
8990 };
8991 if !matches!(
8992 indexed.purpose.status,
8993 PurposeStatus::Approved | PurposeStatus::Stale
8994 ) {
8995 store.set_purpose(path, purpose, PurposeSource::Imported)?;
8996 }
8997 }
8998 Ok(())
8999}
9000
9001#[cfg(test)]
9003pub(crate) fn refresh_structural_summaries_for_nodes(
9004 store: &mut AtlasStore,
9005 nodes: &[Node],
9006 text_rows: &[TextIndexRow],
9007) -> Result<StructuralSummaryReport, CliError> {
9008 let control = standalone_index_work_control();
9009 refresh_structural_summaries_for_nodes_controlled(store, nodes, text_rows, &control)
9010}
9011
9012#[cfg(test)]
9014fn refresh_structural_summaries_for_nodes_controlled(
9015 store: &mut AtlasStore,
9016 nodes: &[Node],
9017 text_rows: &[TextIndexRow],
9018 control: &IndexWorkControl,
9019) -> Result<StructuralSummaryReport, CliError> {
9020 let staged = stage_structural_summaries_for_nodes_controlled(
9021 store,
9022 nodes,
9023 text_rows,
9024 None,
9025 &HashSet::new(),
9026 2,
9027 control,
9028 )?;
9029 apply_structural_summary_stage(store, &staged, control)?;
9030 Ok(staged.report)
9031}
9032
9033fn stage_structural_summaries_for_nodes_controlled(
9035 store: &AtlasStore,
9036 nodes: &[Node],
9037 text_rows: &[TextIndexRow],
9038 symbols: Option<&SymbolBuildStage>,
9039 protected_purpose_paths: &HashSet<String>,
9040 max_workers: usize,
9041 control: &IndexWorkControl,
9042) -> Result<StructuralSummaryStage, CliError> {
9043 control.check(IndexWorkStage::TextIndex)?;
9044 let candidates = nodes
9045 .iter()
9046 .filter(|node| node.kind == NodeKind::File)
9047 .filter(|node| is_structural_summary_candidate(&node.path, node.language.as_deref()))
9048 .collect::<Vec<_>>();
9049 if candidates.is_empty() {
9050 return Ok(StructuralSummaryStage {
9051 report: StructuralSummaryReport::default(),
9052 changes: Vec::new(),
9053 retained_bytes: 0,
9054 });
9055 }
9056 let paths = candidates
9057 .iter()
9058 .map(|node| node.path.clone())
9059 .collect::<Vec<_>>();
9060 let indexed_nodes = store
9061 .load_nodes_by_paths(&paths)?
9062 .into_iter()
9063 .map(|indexed| (indexed.node.path.clone(), indexed))
9064 .collect::<HashMap<_, _>>();
9065 let symbol_counts = store.symbol_counts_for_paths(&paths)?;
9066 let text_by_path = text_rows
9067 .iter()
9068 .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
9069 .collect::<HashMap<_, _>>();
9070 let reason_by_path = text_rows
9071 .iter()
9072 .map(|row| (row.path.as_str(), row.reason))
9073 .collect::<HashMap<_, _>>();
9074 let mut staged_symbol_counts = HashMap::new();
9075 let mut staged_symbol_summaries = HashMap::new();
9076 let mut staged_structural_summaries = HashMap::new();
9077 if let Some(symbols) = symbols {
9078 for change in &symbols.changes {
9079 match change {
9080 SymbolProjectionChange::Parsed(parsed) => {
9081 staged_symbol_counts.insert(parsed.path.as_str(), parsed.graph.symbols.len());
9082 staged_symbol_summaries.insert(parsed.path.as_str(), parsed.summary.as_str());
9083 if parsed.summary_is_structural {
9084 staged_structural_summaries
9085 .insert(parsed.path.as_str(), parsed.purpose_suggestion.is_some());
9086 }
9087 }
9088 SymbolProjectionChange::Clear { path, .. } => {
9089 staged_symbol_counts.insert(path.as_str(), 0);
9090 }
9091 }
9092 }
9093 }
9094 let mut report = StructuralSummaryReport {
9095 candidates: paths.len(),
9096 ..StructuralSummaryReport::default()
9097 };
9098 let worker_count = worker_count_for_work(candidates.len(), max_workers);
9099 let pool = ThreadPoolBuilder::new()
9100 .num_threads(worker_count)
9101 .build()
9102 .map_err(|source| {
9103 CliError::InvalidInput(format!("structural summary worker pool failed: {source}"))
9104 })?;
9105 let derivations = pool.install(|| {
9106 candidates
9107 .par_iter()
9108 .map(|node| -> Result<StructuralSummaryDerivation, CliError> {
9109 control.check(IndexWorkStage::TextIndex)?;
9110 let existing = indexed_nodes.get(&node.path);
9111 let max_bytes = source_input_byte_limit(
9112 &node.path,
9113 node.language.as_deref(),
9114 node.size_bytes,
9115 MAX_SYMBOL_FILE_BYTES,
9116 IndexWorkStage::TextIndex,
9117 )?;
9118 if reason_by_path.get(node.path.as_str()) == Some(&TextIndexSkipReason::TooLarge)
9119 || node
9120 .size_bytes
9121 .is_some_and(|size_bytes| size_bytes > max_bytes)
9122 {
9123 return Ok(StructuralSummaryDerivation {
9124 change: Some(StructuralSummaryChange::Clear {
9125 path: node.path.clone(),
9126 }),
9127 cleared: 1,
9128 too_large: 1,
9129 retained_bytes: node.path.len() as u64,
9130 ..StructuralSummaryDerivation::default()
9131 });
9132 }
9133 let Some(text) = text_by_path.get(node.path.as_str()) else {
9134 return Ok(StructuralSummaryDerivation {
9135 change: Some(StructuralSummaryChange::Clear {
9136 path: node.path.clone(),
9137 }),
9138 cleared: 1,
9139 binary_or_non_utf8: usize::from(
9140 reason_by_path.get(node.path.as_str())
9141 == Some(&TextIndexSkipReason::BinaryOrNonUtf8),
9142 ),
9143 retained_bytes: node.path.len() as u64,
9144 ..StructuralSummaryDerivation::default()
9145 });
9146 };
9147 if let Some(purpose_suggested) = staged_structural_summaries.get(node.path.as_str())
9148 {
9149 return Ok(StructuralSummaryDerivation {
9150 summarized: 1,
9151 purpose_suggestions: usize::from(*purpose_suggested),
9152 ..StructuralSummaryDerivation::default()
9153 });
9154 }
9155 let symbol_count = staged_symbol_counts
9156 .get(node.path.as_str())
9157 .copied()
9158 .or_else(|| symbol_counts.get(node.path.as_str()).copied())
9159 .unwrap_or_default();
9160 let effective_summary = staged_symbol_summaries
9161 .get(node.path.as_str())
9162 .copied()
9163 .or_else(|| existing.and_then(|indexed| indexed.summary.as_deref()));
9164 if symbol_count > 0
9165 && effective_summary.is_some_and(|summary| {
9166 !summary.trim().is_empty() && !is_scanner_fallback_summary(summary)
9167 })
9168 {
9169 return Ok(StructuralSummaryDerivation::default());
9170 }
9171 let Some(summary) = structural_summary_for_path(
9172 &node.path,
9173 node.language.as_deref(),
9174 &text.content,
9175 ) else {
9176 return Ok(StructuralSummaryDerivation {
9177 change: Some(StructuralSummaryChange::Clear {
9178 path: node.path.clone(),
9179 }),
9180 cleared: 1,
9181 retained_bytes: node.path.len() as u64,
9182 ..StructuralSummaryDerivation::default()
9183 });
9184 };
9185 let purpose_needs_suggestion = !protected_purpose_paths.contains(&node.path)
9186 && existing.is_none_or(|indexed| {
9187 matches!(
9188 indexed.purpose.status,
9189 PurposeStatus::Missing | PurposeStatus::Suggested
9190 )
9191 });
9192 let purpose_suggestion =
9193 purpose_needs_suggestion.then(|| suggest_file_purpose(&node.path, &summary));
9194 let purpose_suggestions = usize::from(purpose_suggestion.is_some());
9195 let retained_bytes = (node.path.len() as u64)
9196 .saturating_add(summary.len() as u64)
9197 .saturating_add(
9198 purpose_suggestion
9199 .as_ref()
9200 .map_or(0, |suggestion| suggestion.len() as u64),
9201 );
9202 control.check(IndexWorkStage::TextIndex)?;
9203 Ok(StructuralSummaryDerivation {
9204 change: Some(StructuralSummaryChange::Set {
9205 path: node.path.clone(),
9206 summary,
9207 purpose_suggestion,
9208 }),
9209 summarized: 1,
9210 purpose_suggestions,
9211 retained_bytes,
9212 ..StructuralSummaryDerivation::default()
9213 })
9214 })
9215 .collect::<Result<Vec<_>, CliError>>()
9216 })?;
9217 let mut changes = Vec::new();
9218 let mut retained_bytes = 0_u64;
9219 for derivation in derivations {
9220 report.summarized += derivation.summarized;
9221 report.cleared += derivation.cleared;
9222 report.too_large += derivation.too_large;
9223 report.binary_or_non_utf8 += derivation.binary_or_non_utf8;
9224 report.purpose_suggestions += derivation.purpose_suggestions;
9225 retained_bytes = retained_bytes.saturating_add(derivation.retained_bytes);
9226 if let Some(change) = derivation.change {
9227 changes.push(change);
9228 }
9229 }
9230 Ok(StructuralSummaryStage {
9231 report,
9232 changes,
9233 retained_bytes,
9234 })
9235}
9236
9237fn apply_structural_summary_stage(
9239 store: &mut AtlasStore,
9240 staged: &StructuralSummaryStage,
9241 control: &IndexWorkControl,
9242) -> Result<(), CliError> {
9243 for change in &staged.changes {
9244 control.check(IndexWorkStage::Publication)?;
9245 match change {
9246 StructuralSummaryChange::Set {
9247 path,
9248 summary,
9249 purpose_suggestion,
9250 } => {
9251 store.set_node_summary(path, summary)?;
9252 if let Some(suggestion) = purpose_suggestion.as_deref() {
9253 store.set_suggested_purpose(path, suggestion)?;
9254 }
9255 }
9256 StructuralSummaryChange::Clear { path } => store.clear_node_summary(path)?,
9257 }
9258 }
9259 control.check(IndexWorkStage::Publication)?;
9260 Ok(())
9261}
9262
9263#[cfg(test)]
9265pub(crate) fn refresh_text_index_for_nodes(
9266 store: &mut AtlasStore,
9267 root: &Path,
9268 nodes: &[Node],
9269 options: TextIndexOptions,
9270) -> Result<TextIndexReport, CliError> {
9271 let control = standalone_index_work_control();
9272 Ok(
9273 refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)?
9274 .report,
9275 )
9276}
9277
9278#[cfg(test)]
9280pub(crate) fn refresh_text_index_for_nodes_with_rows(
9281 store: &mut AtlasStore,
9282 root: &Path,
9283 nodes: &[Node],
9284 options: TextIndexOptions,
9285) -> Result<TextIndexRefresh, CliError> {
9286 let control = standalone_index_work_control();
9287 refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)
9288}
9289
9290#[cfg(test)]
9292fn refresh_text_index_for_nodes_with_rows_controlled(
9293 store: &mut AtlasStore,
9294 root: &Path,
9295 nodes: &[Node],
9296 options: TextIndexOptions,
9297 control: &IndexWorkControl,
9298) -> Result<TextIndexRefresh, CliError> {
9299 let file_paths = nodes
9300 .iter()
9301 .filter(|node| node.kind == NodeKind::File)
9302 .map(|node| node.path.clone())
9303 .collect::<Vec<_>>();
9304 refresh_text_index_for_changed_paths_with_rows_controlled(
9305 store,
9306 root,
9307 &file_paths,
9308 nodes,
9309 options,
9310 control,
9311 )
9312}
9313
9314#[cfg(test)]
9316fn refresh_text_index_for_changed_paths_with_rows_controlled(
9317 store: &mut AtlasStore,
9318 root: &Path,
9319 considered_paths: &[String],
9320 nodes: &[Node],
9321 options: TextIndexOptions,
9322 control: &IndexWorkControl,
9323) -> Result<TextIndexRefresh, CliError> {
9324 let staged = stage_text_index_for_changed_paths_controlled(root, nodes, options, control)?;
9325 apply_text_index_stage(store, considered_paths, &staged, control)?;
9326 Ok(staged)
9327}
9328
9329fn stage_text_index_for_changed_paths_controlled(
9331 root: &Path,
9332 nodes: &[Node],
9333 options: TextIndexOptions,
9334 control: &IndexWorkControl,
9335) -> Result<TextIndexRefresh, CliError> {
9336 control.check(IndexWorkStage::TextIndex)?;
9337 let text_rows = indexed_file_texts_for_nodes_controlled(root, nodes, options, control)?;
9338 let indexed = text_rows.iter().filter(|row| row.text.is_some()).count();
9339 let indexed_bytes = text_rows
9340 .iter()
9341 .filter_map(|row| row.text.as_ref())
9342 .map(|text| text.byte_count)
9343 .fold(0usize, usize::saturating_add);
9344 let file_candidates = nodes
9345 .iter()
9346 .filter(|node| node.kind == NodeKind::File)
9347 .count();
9348 let binary_or_non_utf8 = text_rows
9349 .iter()
9350 .filter(|row| row.reason == TextIndexSkipReason::BinaryOrNonUtf8)
9351 .count();
9352 let too_large = text_rows
9353 .iter()
9354 .filter(|row| row.reason == TextIndexSkipReason::TooLarge)
9355 .count();
9356 let report = TextIndexReport {
9357 candidates: file_candidates,
9358 indexed,
9359 binary_or_non_utf8,
9360 too_large,
9361 skipped: file_candidates.saturating_sub(indexed),
9362 max_bytes: options.max_bytes,
9363 bytes: indexed_bytes,
9364 };
9365 control.check(IndexWorkStage::TextIndex)?;
9366 Ok(TextIndexRefresh {
9367 report,
9368 rows: text_rows,
9369 })
9370}
9371
9372fn apply_text_index_stage(
9374 store: &mut AtlasStore,
9375 considered_paths: &[String],
9376 staged: &TextIndexRefresh,
9377 control: &IndexWorkControl,
9378) -> Result<(), CliError> {
9379 let text_by_path = staged
9380 .rows
9381 .iter()
9382 .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
9383 .collect::<HashMap<_, _>>();
9384 for paths in considered_paths.chunks(PUBLICATION_TEXT_BATCH_SIZE) {
9385 control.check(IndexWorkStage::Publication)?;
9386 store.replace_file_texts_for_paths(
9387 paths,
9388 paths
9389 .iter()
9390 .filter_map(|path| text_by_path.get(path.as_str()).copied()),
9391 )?;
9392 }
9393 control.check(IndexWorkStage::Publication)?;
9394 Ok(())
9395}
9396
9397#[cfg(test)]
9399pub(crate) fn indexed_file_texts_for_nodes(
9400 root: &Path,
9401 nodes: &[Node],
9402 options: TextIndexOptions,
9403) -> Result<Vec<TextIndexRow>, CliError> {
9404 let control = standalone_index_work_control();
9405 indexed_file_texts_for_nodes_controlled(root, nodes, options, &control)
9406}
9407
9408fn indexed_file_texts_for_nodes_controlled(
9410 root: &Path,
9411 nodes: &[Node],
9412 options: TextIndexOptions,
9413 control: &IndexWorkControl,
9414) -> Result<Vec<TextIndexRow>, CliError> {
9415 indexed_file_texts_for_nodes_with_limit(root, nodes, options, MAX_STAGED_TEXT_BYTES, control)
9416}
9417
9418fn indexed_file_texts_for_nodes_with_limit(
9420 root: &Path,
9421 nodes: &[Node],
9422 options: TextIndexOptions,
9423 max_staged_bytes: u64,
9424 control: &IndexWorkControl,
9425) -> Result<Vec<TextIndexRow>, CliError> {
9426 let mut rows = Vec::new();
9427 let mut staged_bytes = 0_u64;
9428 for node in nodes.iter().filter(|node| node.kind == NodeKind::File) {
9429 control.check(IndexWorkStage::TextIndex)?;
9430 let max_bytes = source_input_byte_limit(
9431 &node.path,
9432 node.language.as_deref(),
9433 node.size_bytes,
9434 options.max_bytes,
9435 IndexWorkStage::TextIndex,
9436 )?;
9437 if node
9438 .size_bytes
9439 .is_some_and(|size_bytes| size_bytes > max_bytes)
9440 {
9441 rows.push(TextIndexRow {
9442 path: node.path.clone(),
9443 text: None,
9444 reason: TextIndexSkipReason::TooLarge,
9445 });
9446 continue;
9447 }
9448 let remaining_staged_bytes = max_staged_bytes.saturating_sub(staged_bytes);
9449 let is_document = document_format_for_path(&node.path, node.language.as_deref()).is_some();
9450 if !is_document
9451 && node
9452 .size_bytes
9453 .is_some_and(|size_bytes| size_bytes > remaining_staged_bytes)
9454 {
9455 return Err(IndexWorkFailure::resource_limit(
9456 IndexWorkStage::TextIndex,
9457 IndexWorkResource::TextBytes,
9458 max_staged_bytes,
9459 staged_bytes.saturating_add(node.size_bytes.unwrap_or_default()),
9460 )
9461 .into());
9462 }
9463 let native_path = root.join(repo_path_to_native(&node.path));
9464 let read_limit = if is_document {
9465 max_bytes
9466 } else {
9467 max_bytes.min(remaining_staged_bytes)
9468 };
9469 let aggregate_limit_is_narrower = !is_document && remaining_staged_bytes <= max_bytes;
9470 let bytes = match read_source_bytes_controlled(
9471 &native_path,
9472 read_limit,
9473 IndexWorkStage::TextIndex,
9474 control,
9475 ) {
9476 Ok(bytes) => bytes,
9477 Err(SourceReadFailure::Io(source)) => {
9478 return Err(CliError::Io {
9479 path: native_path,
9480 source,
9481 });
9482 }
9483 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
9484 Err(SourceReadFailure::LimitExceeded { observed }) if aggregate_limit_is_narrower => {
9485 return Err(IndexWorkFailure::resource_limit(
9486 IndexWorkStage::TextIndex,
9487 IndexWorkResource::TextBytes,
9488 max_staged_bytes,
9489 staged_bytes.saturating_add(observed),
9490 )
9491 .into());
9492 }
9493 Err(SourceReadFailure::LimitExceeded { .. }) => {
9494 return Err(source_changed_during_derivation(root, &node.path));
9495 }
9496 };
9497 control.check(IndexWorkStage::TextIndex)?;
9498 let current_hash = blake3::hash(&bytes).to_hex().to_string();
9499 if node.content_hash.as_deref() != Some(current_hash.as_str()) {
9500 return Err(source_changed_during_derivation(root, &node.path));
9501 }
9502 let content = if is_document {
9503 extract_document_text_controlled(&bytes, &node.path, node.language.as_deref(), control)
9504 .map_err(|error| document_navigation_error(&node.path, error))?
9505 .text
9506 } else {
9507 let Ok(content) = String::from_utf8(bytes) else {
9508 rows.push(TextIndexRow {
9509 path: node.path.clone(),
9510 text: None,
9511 reason: TextIndexSkipReason::BinaryOrNonUtf8,
9512 });
9513 continue;
9514 };
9515 content
9516 };
9517 let next_staged_bytes = staged_bytes.saturating_add(content.len() as u64);
9518 if next_staged_bytes > max_staged_bytes {
9519 return Err(IndexWorkFailure::resource_limit(
9520 IndexWorkStage::TextIndex,
9521 IndexWorkResource::TextBytes,
9522 max_staged_bytes,
9523 next_staged_bytes,
9524 )
9525 .into());
9526 }
9527 staged_bytes = next_staged_bytes;
9528 rows.push(TextIndexRow {
9529 path: node.path.clone(),
9530 reason: TextIndexSkipReason::Indexed,
9531 text: Some(IndexedFileText {
9532 path: node.path.clone(),
9533 content_hash: node.content_hash.clone(),
9534 byte_count: content.len(),
9535 line_count: content.lines().count(),
9536 content,
9537 }),
9538 });
9539 }
9540 Ok(rows)
9541}
9542
9543fn indexed_file_hashes(
9545 store: &AtlasStore,
9546 control: &IndexWorkControl,
9547) -> Result<HashMap<String, String>, CliError> {
9548 let mut hashes = HashMap::new();
9549 store.visit_source_nodes_controlled(control, |node| {
9550 if node.kind == NodeKind::File
9551 && let Some(hash) = node.content_hash
9552 {
9553 hashes.insert(node.path, hash);
9554 }
9555 Ok(())
9556 })?;
9557 Ok(hashes)
9558}
9559
9560pub(crate) fn indexed_file_hashes_for_paths(
9562 store: &AtlasStore,
9563 paths: &HashSet<String>,
9564) -> Result<HashMap<String, String>, CliError> {
9565 let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
9566 sorted_paths.sort();
9567 Ok(store
9568 .load_nodes_by_paths(&sorted_paths)?
9569 .into_iter()
9570 .filter(|node| node.node.kind == NodeKind::File)
9571 .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
9572 .collect::<HashMap<_, _>>())
9573}
9574
9575pub(crate) fn sorted_watch_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
9577 let mut paths = paths.iter().cloned().collect::<Vec<_>>();
9578 paths.sort();
9579 paths
9580}
9581
9582pub(crate) fn normalized_deleted_path(
9584 root: &Path,
9585 path: &Path,
9586) -> Result<Option<String>, CliError> {
9587 match normalize_repo_path(root, path) {
9588 Ok(path) => Ok(valid_watch_relative_path(path)),
9589 Err(projectatlas_core::CoreError::PathOutsideRoot { .. }) => {
9590 Ok(native_display_relative_path(root, path).and_then(valid_watch_relative_path))
9591 }
9592 Err(source) => Err(CliError::InvalidInput(source.to_string())),
9593 }
9594}
9595
9596pub(crate) fn strip_legacy_purpose(
9598 root: &Path,
9599 config_path: Option<&Path>,
9600 apply: bool,
9601 dry_run: bool,
9602 strip_source_headers: bool,
9603) -> Result<LegacyPurposeReport, CliError> {
9604 let root = root.canonicalize().map_err(|source| CliError::Io {
9605 path: root.to_path_buf(),
9606 source,
9607 })?;
9608 let scan_options = scan_options_for_root(config_path, &root)?;
9609 let nodes = scan_repo(&root, &scan_options)?;
9610 let effective_dry_run = dry_run || !apply;
9611 let purpose_files = indexed_purpose_files(&root, &nodes);
9612 let mut removed = 0;
9613 if !effective_dry_run {
9614 for path in &purpose_files {
9615 let native = root.join(repo_path_to_native(path));
9616 fs::remove_file(&native).map_err(|source| CliError::Io {
9617 path: native,
9618 source,
9619 })?;
9620 removed += 1;
9621 }
9622 }
9623 let source_header_candidates = if strip_source_headers {
9624 purpose_header_candidates(&root, &nodes)?
9625 } else {
9626 Vec::new()
9627 };
9628 Ok(LegacyPurposeReport {
9629 applied: !effective_dry_run,
9630 purpose_files_found: purpose_files.len(),
9631 purpose_files_removed: removed,
9632 source_header_candidates,
9633 purpose_files,
9634 })
9635}
9636
9637pub(crate) fn indexed_purpose_files(root: &Path, nodes: &[Node]) -> Vec<String> {
9639 let mut purpose_files = Vec::new();
9640 for node in nodes.iter().filter(|node| node.kind == NodeKind::Folder) {
9641 let relative = if node.path == "." {
9642 ".purpose".to_string()
9643 } else {
9644 format!("{}/.purpose", node.path)
9645 };
9646 let native = root.join(repo_path_to_native(&relative));
9647 if native.exists() {
9648 purpose_files.push(relative);
9649 }
9650 }
9651 purpose_files.sort();
9652 purpose_files
9653}
9654
9655pub(crate) fn purpose_header_candidates(
9657 root: &Path,
9658 nodes: &[Node],
9659) -> Result<Vec<String>, CliError> {
9660 let mut candidates = Vec::new();
9661 for node in nodes
9662 .iter()
9663 .filter(|node| node.kind == NodeKind::File)
9664 .filter(|node| is_symbol_candidate(&node.path, node.language.as_deref()))
9665 {
9666 let path = root.join(repo_path_to_native(&node.path));
9667 let content = fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?;
9668 if content
9669 .lines()
9670 .take(3)
9671 .any(|line| line.trim_start().contains("Purpose:"))
9672 {
9673 candidates.push(node.path.clone());
9674 }
9675 }
9676 Ok(candidates)
9677}
9678
9679#[cfg(test)]
9680mod tests {
9681 use super::*;
9682 use projectatlas_core::graph::{
9683 DocumentTargetUnresolvedReason, EntitySelector, ExtendedRelationKind, GraphRelationKind,
9684 RelationResolution, RepositoryNodePath,
9685 };
9686 use projectatlas_db::{DbError, RepositoryGraphRelationQuery, WorktreeAlias};
9687 use std::error::Error;
9688 use std::fmt::Debug;
9689 use std::process::Command as StdCommand;
9690
9691 #[test]
9692 fn guarded_reset_restores_owned_files_or_retains_them_on_namespace_change()
9693 -> Result<(), Box<dyn Error>> {
9694 for replace_namespace in [false, true] {
9695 let temp = tempfile::tempdir()?;
9696 let root = temp.path().canonicalize()?;
9697 let directory = root.join(".projectatlas");
9698 fs::create_dir(&directory)?;
9699 let database = directory.join("projectatlas.db");
9700 let wal = db_sidecar_path(&database, "wal");
9701 fs::write(&database, b"original main")?;
9702 fs::write(&wal, b"original WAL")?;
9703 let retained = root.join("captured-state");
9704 let admission = preflight_managed_reset(&database, &root)?;
9705 let mut injected = false;
9706 let result =
9707 reset_index_files_with_revalidation(&database, false, admission.as_ref(), || {
9708 if injected || database.exists() {
9709 return Ok(());
9710 }
9711 injected = true;
9712 if !replace_namespace {
9713 return Err(CliError::InvalidInput(
9714 "injected reset lifecycle failure".to_owned(),
9715 ));
9716 }
9717 fs::rename(&directory, &retained)
9718 .and_then(|()| fs::create_dir(&directory))
9719 .and_then(|()| fs::write(&database, b"foreign main"))
9720 .and_then(|()| fs::write(&wal, b"foreign WAL"))
9721 .map_err(|source| CliError::Io {
9722 path: directory.clone(),
9723 source,
9724 })
9725 });
9726 let error = result
9727 .err()
9728 .ok_or_else(|| io::Error::other("injected reset failure was ignored"))?;
9729 if replace_namespace {
9730 if !error.to_string().contains("staged reset recovery retained") {
9731 return Err(io::Error::other(format!(
9732 "namespace failure did not report retention: {error}"
9733 ))
9734 .into());
9735 }
9736 require_eq(
9737 &fs::read(&database)?,
9738 &b"foreign main".to_vec(),
9739 "reset recovery modified replacement main",
9740 )?;
9741 require_eq(
9742 &fs::read(&wal)?,
9743 &b"foreign WAL".to_vec(),
9744 "reset recovery modified replacement WAL",
9745 )?;
9746 require_eq(
9747 &fs::read(retained.join("projectatlas.db-wal"))?,
9748 &b"original WAL".to_vec(),
9749 "reset lost unstaged WAL",
9750 )?;
9751 let recoveries = fs::read_dir(&retained)?.collect::<Result<Vec<_>, _>>()?;
9752 let recovery = recoveries
9753 .iter()
9754 .find(|entry| {
9755 entry
9756 .file_name()
9757 .to_string_lossy()
9758 .starts_with("reset-recovery-")
9759 })
9760 .ok_or_else(|| io::Error::other("reset did not retain its staged main"))?;
9761 let staged_files = fs::read_dir(recovery.path())?.collect::<Result<Vec<_>, _>>()?;
9762 require_eq(
9763 &staged_files.len(),
9764 &1,
9765 "reset retained unexpected staged files",
9766 )?;
9767 require_eq(
9768 &fs::read(staged_files[0].path())?,
9769 &b"original main".to_vec(),
9770 "reset lost staged main",
9771 )?;
9772 } else {
9773 if !error
9774 .to_string()
9775 .contains("injected reset lifecycle failure")
9776 {
9777 return Err(
9778 io::Error::other(format!("reset lost lifecycle failure: {error}")).into(),
9779 );
9780 }
9781 require_eq(
9782 &fs::read(&database)?,
9783 &b"original main".to_vec(),
9784 "reset did not restore owned main",
9785 )?;
9786 require_eq(
9787 &fs::read(&wal)?,
9788 &b"original WAL".to_vec(),
9789 "reset modified unstaged WAL",
9790 )?;
9791 if fs::read_dir(&directory)?.count() != 2 {
9792 return Err(io::Error::other("restored reset left recovery artifacts").into());
9793 }
9794 }
9795 }
9796 Ok(())
9797 }
9798
9799 #[cfg(windows)]
9801 #[test]
9802 fn windows_managed_database_aliases_preserve_wal_recovery_and_reset()
9803 -> Result<(), Box<dyn Error>> {
9804 let short_path = |path: &Path| -> Result<PathBuf, Box<dyn Error>> {
9805 let output = StdCommand::new("powershell.exe")
9806 .args(["-NoProfile", "-NonInteractive", "-Command", "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); (New-Object -ComObject Scripting.FileSystemObject).GetFile($env:PROJECTATLAS_TEST_DB_PATH).ShortPath"])
9807 .env("PROJECTATLAS_TEST_DB_PATH", path).output()?;
9808 if !output.status.success() {
9809 return Err(
9810 io::Error::other(format!("short database fixture failed: {output:?}")).into(),
9811 );
9812 }
9813 Ok(PathBuf::from(String::from_utf8(output.stdout)?.trim()))
9814 };
9815 let mut actual_aliases = 0;
9816 for sensitive in [false, true] {
9817 let temp = tempfile::tempdir()?;
9818 let root = temp.path().canonicalize()?;
9819 let directory = root.join(".projectatlas");
9820 fs::create_dir(&directory)?;
9821 let configured = StdCommand::new("fsutil.exe")
9822 .current_dir(&directory)
9823 .args([
9824 "file",
9825 "setCaseSensitiveInfo",
9826 ".",
9827 if sensitive { "enable" } else { "disable" },
9828 ])
9829 .output()?;
9830 if !configured.status.success() {
9831 return Err(io::Error::other(format!(
9832 "database case fixture failed: {configured:?}"
9833 ))
9834 .into());
9835 }
9836 let database = directory.join("projectatlas.db");
9837 let normal_database = temp.path().join(".projectatlas/projectatlas.db");
9838 let config = directory.join("config.toml");
9839 fs::write(&config, "[project]\nroot = \".\"\n")?;
9840 fs::write(&database, b"admission-only main")?;
9842 let wal = db_sidecar_path(&database, "wal");
9843 fs::write(&wal, b"outside WAL owner")?;
9844 let outside_wal = root.join("admission-outside-wal");
9845 fs::hard_link(&wal, &outside_wal)?;
9846 for suffix in [".", " "] {
9847 let selected = normal_database.with_file_name(format!("projectatlas.db{suffix}"));
9848 if preflight_managed_database(&selected, &root).is_ok() {
9849 return Err(
9850 io::Error::other("normal suffix bypassed canonical WAL ownership").into(),
9851 );
9852 }
9853 }
9854 fs::remove_file(&wal)?;
9855 fs::remove_file(&outside_wal)?;
9856 fs::remove_file(&database)?;
9857 let mut canonical = open_atlas_store_for_project(&database, &root)?;
9858 canonical.upsert_scan_nodes(&[projectatlas_core::Node {
9859 path: "file.rs".to_owned(),
9860 kind: projectatlas_core::NodeKind::File,
9861 parent_path: None,
9862 extension: Some(".rs".to_owned()),
9863 language: Some("rust".to_owned()),
9864 size_bytes: Some(1),
9865 mtime_ns: None,
9866 content_hash: None,
9867 }])?;
9868 canonical.set_purpose(
9869 "file.rs",
9870 "canonical purpose",
9871 projectatlas_core::PurposeSource::Agent,
9872 )?;
9873 let identity = canonical.project_instance_id()?;
9874 let short = short_path(&normal_database)?;
9875 if short.file_name() == normal_database.file_name() {
9876 drop(canonical);
9877 continue;
9878 }
9879 actual_aliases += 1;
9880 let selected = open_existing_atlas_store_for_project(&short, &root)?;
9881 require_eq(
9882 &selected.authored_purpose_revision()?,
9883 &1,
9884 "alias did not see canonical committed state",
9885 )?;
9886 selected.set_purpose(
9887 "file.rs",
9888 "shared alias purpose",
9889 projectatlas_core::PurposeSource::Agent,
9890 )?;
9891 require_eq(
9892 &canonical.authored_purpose_revision()?,
9893 &2,
9894 "new managed opens formed separate WAL cohorts",
9895 )?;
9896 drop(selected);
9897 drop(canonical);
9898 let short_wal = db_sidecar_path(&short, "wal");
9899 let short_shm = db_sidecar_path(&short, "shm");
9900 if short_wal.exists() || short_shm.exists() {
9901 return Err(io::Error::other("managed alias created short sidecars").into());
9902 }
9903
9904 let legacy = rusqlite::Connection::open(&short)?;
9906 legacy.execute_batch("PRAGMA wal_autocheckpoint=0; BEGIN IMMEDIATE; UPDATE metadata SET value='9' WHERE key='purpose.authored_revision'; UPDATE purposes SET purpose='pending legacy purpose'; COMMIT;")?;
9907 let main_before = fs::read(&database)?;
9908 let wal_before = fs::read(&short_wal)?;
9909 let shm_before = fs::read(&short_shm)?;
9910 for path in [&database, &short] {
9911 if open_atlas_store_for_project(path, &root).is_ok()
9912 || open_existing_atlas_store_for_project(path, &root).is_ok()
9913 || open_atlas_store_read_only_for_project(path, &root).is_ok()
9914 || preflight_existing_project_binding(path, &root).is_ok()
9915 || build_settings_report(path, Some(&config), OutputFormat::Json).is_ok()
9916 || crate::build_root_report(path, Some(&config)).is_ok()
9917 || run_init_bootstrap(
9918 &root,
9919 path,
9920 Some(&config),
9921 &InitBootstrapOptions {
9922 no_scan: true,
9923 force_rescan: false,
9924 text_index_max_bytes: None,
9925 },
9926 )
9927 .is_ok()
9928 {
9929 return Err(io::Error::other(
9930 "pending short cohort was silently opened or retargeted",
9931 )
9932 .into());
9933 }
9934 }
9935 require_eq(
9936 &fs::read(&database)?,
9937 &main_before,
9938 "refusal changed the main database",
9939 )?;
9940 require_eq(
9941 &fs::read(&short_wal)?,
9942 &wal_before,
9943 "refusal changed committed short WAL",
9944 )?;
9945 require_eq(
9946 &fs::read(&short_shm)?,
9947 &shm_before,
9948 "refusal changed short SHM",
9949 )?;
9950 legacy.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
9952 drop(legacy);
9953 let recovered = open_existing_atlas_store_for_project(&short, &root)?;
9954 require_eq(
9955 &recovered.authored_purpose_revision()?,
9956 &9,
9957 "checkpointed authored state was lost",
9958 )?;
9959 require_eq(
9960 &recovered.project_instance_id()?,
9961 &identity,
9962 "cohort recovery changed project identity",
9963 )?;
9964 drop(recovered);
9965
9966 let legacy = rusqlite::Connection::open(&short)?;
9968 legacy.execute_batch("PRAGMA wal_autocheckpoint=0; UPDATE metadata SET value='10' WHERE key='purpose.authored_revision';")?;
9969 let crash_main = fs::read(&database)?;
9970 let crash_wal = fs::read(&short_wal)?;
9971 let crash_shm = fs::read(&short_shm)?;
9972 drop(legacy);
9973 fs::write(&database, &crash_main)?;
9974 fs::write(&short_wal, &crash_wal)?;
9975 fs::write(&short_shm, &crash_shm)?;
9976 let outside_main = root.join("reset-outside-main");
9977 fs::hard_link(&database, &outside_main)?;
9978 let outside_wal = root.join("reset-outside-wal");
9979 fs::hard_link(&short_wal, &outside_wal)?;
9980 let admission = preflight_managed_reset(&short, &root)?;
9981 let preview = reset_index_files(&short, false, true, false, admission.as_ref())?;
9982 let alternate_leaf = short
9983 .file_name()
9984 .ok_or_else(|| io::Error::other("short database has no leaf"))?;
9985 let canonical_parent_wal = db_sidecar_path(&directory.join(alternate_leaf), "wal");
9986 if !preview
9987 .files
9988 .iter()
9989 .any(|file| file.path == lossless_native_path_display(&canonical_parent_wal))
9990 || !same_file::is_same_file(&canonical_parent_wal, &short_wal)?
9991 {
9992 return Err(io::Error::other(
9993 "reset preview omitted proven alternate recovery state",
9994 )
9995 .into());
9996 }
9997 require_eq(
9998 &fs::read(&short_wal)?,
9999 &crash_wal,
10000 "reset preview changed pending state",
10001 )?;
10002 reset_index_files(&short, true, false, false, admission.as_ref())?;
10003 if database.exists() || short_wal.exists() || short_shm.exists() {
10004 return Err(
10005 io::Error::other("explicit reset left a database cohort behind").into(),
10006 );
10007 }
10008 require_eq(
10009 &fs::read(&outside_main)?,
10010 &crash_main,
10011 "reset modified the outside main hardlink",
10012 )?;
10013 require_eq(
10014 &fs::read(&outside_wal)?,
10015 &crash_wal,
10016 "reset modified the outside WAL hardlink",
10017 )?;
10018 drop(admission);
10019
10020 let fresh = open_atlas_store_for_project(&database, &root)?;
10022 let identity = fresh.project_instance_id()?;
10023 drop(fresh);
10024 let repair = rusqlite::Connection::open(&database)?;
10025 repair.execute("DELETE FROM project_root_identity", [])?;
10026 drop(repair);
10027 let short = short_path(&normal_database)?;
10028 let observer = rusqlite::Connection::open(&database)?;
10029 let _: i64 =
10030 observer.query_row("SELECT count(*) FROM project_root_identity", [], |row| {
10031 row.get(0)
10032 })?;
10033 run_init_bootstrap(
10034 &root,
10035 &short,
10036 Some(&config),
10037 &InitBootstrapOptions {
10038 no_scan: true,
10039 force_rescan: false,
10040 text_index_max_bytes: None,
10041 },
10042 )?;
10043 if db_sidecar_path(&short, "wal").exists() || db_sidecar_path(&short, "shm").exists() {
10044 return Err(io::Error::other("identity repair created a short-name cohort").into());
10045 }
10046 require_eq(
10047 &open_existing_atlas_store_for_project(&short, &root)?.project_instance_id()?,
10048 &identity,
10049 "alias identity repair changed the instance",
10050 )?;
10051 drop(observer);
10052 }
10053 if actual_aliases == 0 && std::env::var_os("CI").is_some() {
10054 return Err(io::Error::other(
10055 "native Windows proof did not exercise a real 8.3 DB alias",
10056 )
10057 .into());
10058 }
10059 Ok(())
10060 }
10061
10062 fn run_git_fixture(command: &mut StdCommand) -> Result<(), Box<dyn Error>> {
10063 let output = command.output()?;
10064 if !output.status.success() {
10065 return Err(io::Error::other(format!(
10066 "Git fixture command failed: {}{}",
10067 String::from_utf8_lossy(&output.stdout),
10068 String::from_utf8_lossy(&output.stderr)
10069 ))
10070 .into());
10071 }
10072 Ok(())
10073 }
10074
10075 fn drop_native_worktree_identity_schema(
10077 connection: &rusqlite::Connection,
10078 ) -> rusqlite::Result<()> {
10079 connection.execute_batch(
10080 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
10081 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
10082 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
10083 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
10084 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
10085 )
10086 }
10087
10088 #[test]
10089 fn synchronization_control_identity_rejects_replacement_without_caller_identity()
10090 -> Result<(), Box<dyn Error>> {
10091 let temp = tempfile::tempdir()?;
10092 let root = temp.path().join("control");
10093 let state = root.join(".projectatlas");
10094 let database = state.join("projectatlas.db");
10095 fs::create_dir_all(&state)?;
10096 let original = AtlasStore::open_for_project(&database, &root)?;
10097 let captured = require_synchronization_control_identity(&original, None)?;
10098 require_eq(
10099 &require_synchronization_control_identity(&original, Some(captured))?,
10100 &captured,
10101 "stable control identity",
10102 )?;
10103 drop(original);
10104
10105 fs::rename(&state, root.join(".projectatlas-captured-control"))?;
10106 fs::create_dir(&state)?;
10107 let replacement = AtlasStore::open_for_project(&database, &root)?;
10108 let rejected = require_synchronization_control_identity(&replacement, Some(captured));
10109 require_eq(
10110 &rejected
10111 .as_ref()
10112 .is_err_and(|error| error.to_string().contains("control atlas identity changed")),
10113 &true,
10114 "replacement control identity rejection",
10115 )
10116 }
10117
10118 #[cfg(windows)]
10119 #[test]
10120 fn default_mcp_project_root_recovers_custom_predecessor_candidate() -> Result<(), Box<dyn Error>>
10121 {
10122 let temp = tempfile::tempdir()?;
10123 let root = temp.path().join("legacy-project");
10124 let database = temp.path().join("custom-projectatlas.db");
10125 fs::create_dir(&root)?;
10126 drop(AtlasStore::open_for_project(&database, &root)?);
10127 {
10128 let connection = rusqlite::Connection::open(&database)?;
10129 drop_native_worktree_identity_schema(&connection)?;
10130 connection.execute_batch(
10131 "DROP TABLE project_root_identity;
10132 DROP TABLE IF EXISTS graph_identity_rejections;
10133 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
10134 )?;
10135 }
10136
10137 let resolved = default_mcp_project_root(&database, None)?;
10138 require_eq(
10139 &resolved,
10140 &canonical_source_project_root(&root)?,
10141 "custom predecessor root recovery",
10142 )?;
10143 drop(AtlasStore::open_for_project(&database, &root)?);
10144 Ok(())
10145 }
10146
10147 #[test]
10148 fn init_rejects_current_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
10149 let temp = tempfile::tempdir()?;
10150 let selected_root = temp.path().join("selected-root");
10151 let bound_root = temp.path().join("bound-root");
10152 let database = temp.path().join("external-projectatlas.db");
10153 let config_path = selected_root.join("external-config/config.toml");
10154 fs::create_dir_all(&selected_root)?;
10155 fs::create_dir_all(&bound_root)?;
10156 let persisted_identity = {
10157 let store = AtlasStore::open_for_project(&database, &bound_root)?;
10158 store.project_root_identity()?
10159 };
10160 drop(AtlasStore::open_read_only_for_project(
10161 &database,
10162 &bound_root,
10163 )?);
10164 let _ = read_project_root_identity_read_only(&database)?;
10165 let database_before = fs::read(&database)?;
10166 let sidecars_before = ["wal", "shm", "journal"]
10167 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10168 let selected_project_dir = selected_root.join(".projectatlas");
10169 let config_parent = config_path
10170 .parent()
10171 .ok_or_else(|| io::Error::other("selected config has no parent"))?;
10172
10173 let result = run_init_bootstrap(
10174 &selected_root,
10175 &database,
10176 Some(&config_path),
10177 &InitBootstrapOptions {
10178 no_scan: true,
10179 force_rescan: false,
10180 text_index_max_bytes: None,
10181 },
10182 );
10183 require_eq(
10184 &matches!(result, Err(CliError::ProjectMismatch(_))),
10185 &true,
10186 "current wrong-root init did not return a typed project mismatch",
10187 )?;
10188 require_eq(
10189 &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
10190 &true,
10191 "current wrong-root init created selected project or config state",
10192 )?;
10193 require_eq(
10194 &(fs::read(&database)? == database_before
10195 && ["wal", "shm", "journal"]
10196 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
10197 == sidecars_before),
10198 &true,
10199 "current wrong-root init changed database or sidecar state",
10200 )?;
10201 let reopened = AtlasStore::open_read_only_for_project(&database, &bound_root)?;
10202 require_eq(
10203 &reopened.project_root_identity()?,
10204 &persisted_identity,
10205 "current binding after wrong-root init",
10206 )?;
10207 Ok(())
10208 }
10209
10210 #[cfg(any(unix, windows))]
10211 #[test]
10212 fn init_admits_legacy_config_before_repairing_missing_root_identity()
10213 -> Result<(), Box<dyn Error>> {
10214 let temp = tempfile::tempdir()?;
10215 let root = temp.path().join("legacy-admission-root");
10216 let database = temp.path().join("legacy-admission.db");
10217 let outside = temp.path().join("missing-legacy-target.toml");
10218 fs::create_dir(&root)?;
10219 let original_identity = {
10220 let store = AtlasStore::open_for_project(&database, &root)?;
10221 store.project_instance_id()?
10222 };
10223 let connection = rusqlite::Connection::open(&database)?;
10224 connection.execute("DELETE FROM project_root_identity", [])?;
10225 drop(connection);
10226 let config = root.join("projectatlas.toml");
10227 if !create_document_target_symlink(&outside, &config)? {
10228 return Ok(());
10229 }
10230 let database_before = fs::read(&database)?;
10231 let sidecars_before = ["wal", "shm", "journal"]
10232 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10233 let result = run_init_bootstrap(
10234 &root,
10235 &database,
10236 Some(&config),
10237 &InitBootstrapOptions {
10238 no_scan: true,
10239 force_rescan: false,
10240 text_index_max_bytes: None,
10241 },
10242 );
10243 require_eq(
10244 &result.is_err(),
10245 &true,
10246 "dangling managed legacy config was admitted",
10247 )?;
10248 require_eq(
10249 &(fs::read(&database)? == database_before),
10250 &true,
10251 "config refusal happened after database identity repair",
10252 )?;
10253 require_eq(
10254 &["wal", "shm", "journal"]
10255 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok()),
10256 &sidecars_before,
10257 "config refusal changed SQLite sidecars",
10258 )?;
10259 require_eq(
10260 &(!outside.exists() && !root.join(".projectatlas").exists()),
10261 &true,
10262 "config refusal created generated state",
10263 )?;
10264 fs::remove_file(&config)?;
10265 let retry = run_init_bootstrap(
10266 &root,
10267 &database,
10268 Some(&config),
10269 &InitBootstrapOptions {
10270 no_scan: true,
10271 force_rescan: false,
10272 text_index_max_bytes: None,
10273 },
10274 )?;
10275 let reopened = AtlasStore::open_for_project(&database, &root)?;
10276 require_eq(
10277 &(retry.ok && config.is_file()),
10278 &true,
10279 "restored legacy config could not initialize",
10280 )?;
10281 require_eq(
10282 &reopened.project_instance_id()?,
10283 &original_identity,
10284 "supported retry changed the project identity",
10285 )?;
10286 require_eq(
10287 &reopened.project_root_identity()?.is_some(),
10288 &true,
10289 "supported retry did not repair the native root identity",
10290 )?;
10291 Ok(())
10292 }
10293
10294 #[test]
10295 fn init_repairs_current_missing_root_identity_from_bound_metadata() -> Result<(), Box<dyn Error>>
10296 {
10297 let temp = tempfile::tempdir()?;
10298 let root = temp.path().join("incomplete-binding-root");
10299 let database = temp.path().join("incomplete-binding.db");
10300 fs::create_dir(&root)?;
10301 let project_instance_id = {
10302 let store = AtlasStore::open_for_project(&database, &root)?;
10303 store
10304 .project_instance_id()?
10305 .ok_or_else(|| io::Error::other("incomplete binding project identity is missing"))?
10306 };
10307 let connection = rusqlite::Connection::open(&database)?;
10308 connection.execute("DELETE FROM project_root_identity", [])?;
10309 drop(connection);
10310
10311 let ordinary_error = AtlasStore::open_for_project(&database, &root)
10312 .err()
10313 .ok_or_else(|| io::Error::other("ordinary open repaired incomplete binding"))?;
10314 require_eq(
10315 &matches!(ordinary_error, DbError::ProjectRootIdentityMissing),
10316 &true,
10317 "ordinary open returned the wrong incomplete-binding error",
10318 )?;
10319
10320 let wrong_root = temp.path().join("incomplete-binding-wrong-root");
10321 fs::create_dir(&wrong_root)?;
10322 let database_before = fs::read(&database)?;
10323 let sidecars_before = ["wal", "shm", "journal"]
10324 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10325 let wrong_result = run_init_bootstrap(
10326 &wrong_root,
10327 &database,
10328 None,
10329 &InitBootstrapOptions {
10330 no_scan: true,
10331 force_rescan: false,
10332 text_index_max_bytes: None,
10333 },
10334 );
10335 require_eq(
10336 &matches!(wrong_result, Err(CliError::ProjectMismatch(_))),
10337 &true,
10338 "incomplete-binding init accepted a different root",
10339 )?;
10340 require_eq(
10341 &fs::read(&database)?,
10342 &database_before,
10343 "wrong-root incomplete-binding init changed database bytes",
10344 )?;
10345 require_eq(
10346 &["wal", "shm", "journal"]
10347 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok()),
10348 &sidecars_before,
10349 "wrong-root incomplete-binding init changed SQLite sidecars",
10350 )?;
10351
10352 let report = run_init_bootstrap(
10353 &root,
10354 &database,
10355 None,
10356 &InitBootstrapOptions {
10357 no_scan: true,
10358 force_rescan: false,
10359 text_index_max_bytes: None,
10360 },
10361 )?;
10362 require_eq(
10363 &report.ok,
10364 &true,
10365 "explicit init did not repair the incomplete binding",
10366 )?;
10367
10368 let reopened = AtlasStore::open_for_project(&database, &root)?;
10369 require_eq(
10370 &reopened.project_instance_id()?,
10371 &Some(project_instance_id),
10372 "explicit init changed the project identity while repairing the root",
10373 )?;
10374 require_eq(
10375 &reopened.project_root_identity()?,
10376 &Some(projectatlas_core::CanonicalProjectRoot::from_path(&root)?),
10377 "explicit init did not restore the native root identity",
10378 )?;
10379 Ok(())
10380 }
10381
10382 #[cfg(windows)]
10383 #[test]
10384 fn init_rejects_predecessor_wrong_root_before_project_writes() -> Result<(), Box<dyn Error>> {
10385 let temp = tempfile::tempdir()?;
10386 let selected_root = temp.path().join("selected-predecessor-root");
10387 let bound_root = temp.path().join("bound-predecessor-root");
10388 let database = temp.path().join("external-predecessor.db");
10389 let config_path = selected_root.join("external-config/config.toml");
10390 fs::create_dir_all(&selected_root)?;
10391 fs::create_dir_all(&bound_root)?;
10392 drop(AtlasStore::open_for_project(&database, &bound_root)?);
10393 {
10394 let connection = rusqlite::Connection::open(&database)?;
10395 drop_native_worktree_identity_schema(&connection)?;
10396 connection.execute_batch(
10397 "DROP TABLE project_root_identity;
10398 DROP TABLE IF EXISTS graph_identity_rejections;
10399 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
10400 )?;
10401 }
10402 read_legacy_project_root_candidate_read_only(&database)?;
10403 let database_before = fs::read(&database)?;
10404 let sidecars_before = ["wal", "shm", "journal"]
10405 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10406 let selected_project_dir = selected_root.join(".projectatlas");
10407 let config_parent = config_path
10408 .parent()
10409 .ok_or_else(|| io::Error::other("selected predecessor config has no parent"))?;
10410
10411 let result = run_init_bootstrap(
10412 &selected_root,
10413 &database,
10414 Some(&config_path),
10415 &InitBootstrapOptions {
10416 no_scan: true,
10417 force_rescan: false,
10418 text_index_max_bytes: None,
10419 },
10420 );
10421 require_eq(
10422 &matches!(result, Err(CliError::ProjectMismatch(_))),
10423 &true,
10424 "predecessor wrong-root init did not return a typed project mismatch",
10425 )?;
10426 require_eq(
10427 &(!selected_project_dir.exists() && !config_parent.exists() && !config_path.exists()),
10428 &true,
10429 "predecessor wrong-root init created selected project or config state",
10430 )?;
10431 require_eq(
10432 &(fs::read(&database)? == database_before
10433 && ["wal", "shm", "journal"]
10434 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
10435 == sidecars_before),
10436 &true,
10437 "predecessor wrong-root init changed database or sidecar state",
10438 )?;
10439 Ok(())
10440 }
10441
10442 #[cfg(unix)]
10443 #[test]
10444 fn runtime_rejects_non_authoritative_predecessor_before_config_discovery()
10445 -> Result<(), Box<dyn Error>> {
10446 fn is_ambiguous_predecessor(error: &CliError) -> bool {
10447 matches!(error, CliError::Db(DbError::ProjectRootIdentityMissing))
10448 }
10449
10450 use std::ffi::OsString;
10451 use std::os::unix::ffi::OsStringExt;
10452
10453 let temp = tempfile::tempdir()?;
10454 let raw_root = temp
10455 .path()
10456 .join(OsString::from_vec(b"runtime-repo\\name".to_vec()));
10457 let replacement_root = temp.path().join("runtime-repo/name");
10458 let database = temp.path().join("runtime-custom-predecessor.db");
10459 fs::create_dir_all(&raw_root)?;
10460 drop(AtlasStore::open_for_project(&database, &raw_root)?);
10461 {
10462 let connection = rusqlite::Connection::open(&database)?;
10463 drop_native_worktree_identity_schema(&connection)?;
10464 connection.execute_batch(
10465 "DROP TABLE project_root_identity;
10466 DROP TABLE IF EXISTS graph_identity_rejections;
10467 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
10468 )?;
10469 connection.execute(
10470 "INSERT INTO metadata(key, value) VALUES('project_root', ?1)
10471 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
10472 [replacement_root.to_string_lossy().into_owned()],
10473 )?;
10474 }
10475 let replacement_config = replacement_root.join(".projectatlas/config.toml");
10476 fs::create_dir_all(
10477 replacement_config
10478 .parent()
10479 .ok_or_else(|| io::Error::other("replacement config has no parent"))?,
10480 )?;
10481 fs::write(
10482 &replacement_config,
10483 format!(
10484 "[project]\nroot = {}\n",
10485 serde_json::to_string(&replacement_root.to_string_lossy())?
10486 ),
10487 )?;
10488 let replacement_config_before = fs::read(&replacement_config)?;
10489 require_condition(
10490 matches!(
10491 read_legacy_project_root_candidate_read_only(&database),
10492 Err(DbError::ProjectRootIdentityMissing)
10493 ),
10494 "non-authoritative predecessor candidate was exposed",
10495 )?;
10496 let database_before = fs::read(&database)?;
10497 let sidecars_before = ["wal", "shm", "journal"]
10498 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
10499
10500 require_condition(
10501 default_mcp_project_root(&database, None)
10502 .is_err_and(|error| is_ambiguous_predecessor(&error)),
10503 "default MCP discovery admitted an ambiguous predecessor",
10504 )?;
10505 require_condition(
10506 default_cli_project_root(&database, None, false)
10507 .is_err_and(|error| is_ambiguous_predecessor(&error)),
10508 "default CLI discovery admitted an ambiguous predecessor",
10509 )?;
10510 require_condition(
10511 resolved_mcp_config_path(&database, None)
10512 .is_err_and(|error| is_ambiguous_predecessor(&error)),
10513 "MCP config discovery admitted an ambiguous predecessor",
10514 )?;
10515 require_condition(
10516 resolved_mcp_config_path(&database, Some(&replacement_config))
10517 .is_err_and(|error| is_ambiguous_predecessor(&error)),
10518 "explicit MCP config bypassed ambiguous predecessor admission",
10519 )?;
10520 require_condition(
10521 crate::build_harness_mcp_config_report(
10522 crate::HarnessConfig::McpJson,
10523 "ambiguous-predecessor",
10524 &database,
10525 None,
10526 false,
10527 )
10528 .is_err_and(|error| is_ambiguous_predecessor(&error)),
10529 "generated MCP config admitted an ambiguous predecessor",
10530 )?;
10531 require_condition(
10532 build_settings_report(&database, None, OutputFormat::Json)
10533 .is_err_and(|error| is_ambiguous_predecessor(&error)),
10534 "settings discovery admitted an ambiguous predecessor",
10535 )?;
10536 require_condition(
10537 fs::read(&replacement_config)? == replacement_config_before,
10538 "ambiguous predecessor discovery changed the replacement config",
10539 )?;
10540 require_condition(
10541 fs::read(&database)? == database_before
10542 && ["wal", "shm", "journal"]
10543 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
10544 == sidecars_before,
10545 "ambiguous predecessor discovery changed database or sidecars",
10546 )?;
10547 Ok(())
10548 }
10549
10550 #[test]
10551 fn synchronized_repository_read_holds_catalog_writer_exclusion() -> Result<(), Box<dyn Error>> {
10552 let temp = tempfile::tempdir()?;
10553 let root = temp.path().join("control");
10554 let database = root.join(".projectatlas/projectatlas.db");
10555 fs::create_dir_all(
10556 database
10557 .parent()
10558 .ok_or_else(|| io::Error::other("control database has no parent"))?,
10559 )?;
10560 let control = AtlasStore::open_for_project(&database, &root)?;
10561 let control_project = control
10562 .project_instance_id()?
10563 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10564 let writer_blocked = std::cell::Cell::new(false);
10565
10566 let overview = synchronize_registered_worktree_usage_with_catalog_validation(
10567 &database,
10568 &root,
10569 Some(control_project),
10570 |_, _| Ok(()),
10571 || Ok(()),
10572 |reader| {
10573 let contender = rusqlite::Connection::open(&database).map_err(DbError::from)?;
10574 contender
10575 .busy_timeout(Duration::ZERO)
10576 .map_err(DbError::from)?;
10577 match contender.execute_batch("BEGIN IMMEDIATE") {
10578 Err(rusqlite::Error::SqliteFailure(code, _))
10579 if matches!(
10580 code.code,
10581 rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
10582 ) =>
10583 {
10584 writer_blocked.set(true);
10585 }
10586 Ok(()) => contender.execute_batch("ROLLBACK").map_err(DbError::from)?,
10587 Err(error) => return Err(DbError::from(error)),
10588 }
10589 reader.repository_token_overview()
10590 },
10591 )?;
10592 require_eq(
10593 &writer_blocked.get(),
10594 &true,
10595 "repository aggregate read writer exclusion",
10596 )?;
10597 require_eq(&overview.calls, &0, "empty synchronized repository report")
10598 }
10599
10600 #[test]
10601 fn synchronization_rejects_a_registration_committed_after_catalog_capture()
10602 -> Result<(), Box<dyn Error>> {
10603 let temp = tempfile::tempdir()?;
10604 let root = temp.path().join("control");
10605 let database = root.join(".projectatlas/projectatlas.db");
10606 let common = temp.path().join("common.git");
10607 let admin = common.join("worktrees/added");
10608 let target = temp.path().join("added");
10609 for path in [&root, &admin, &target] {
10610 fs::create_dir_all(path)?;
10611 }
10612 fs::create_dir_all(
10613 database
10614 .parent()
10615 .ok_or_else(|| io::Error::other("control database has no parent"))?,
10616 )?;
10617 let control = AtlasStore::open_for_project(&database, &root)?;
10618 let control_project = control
10619 .project_instance_id()?
10620 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10621 let alias = WorktreeAlias::parse("added")?;
10622
10623 let result = synchronize_registered_worktree_usage_with_catalog_validation(
10624 &database,
10625 &root,
10626 Some(control_project),
10627 |_, _| Ok(()),
10628 || {
10629 control.register_worktree(
10630 &alias,
10631 &common,
10632 &admin,
10633 &"01".repeat(32),
10634 &target,
10635 None,
10636 1,
10637 )?;
10638 Ok(())
10639 },
10640 |_| Ok(()),
10641 );
10642 require_eq(
10643 &result.as_ref().is_err_and(|error| {
10644 error
10645 .to_string()
10646 .contains("catalog changed during aggregate synchronization")
10647 }),
10648 &true,
10649 "concurrent registration catalog rejection",
10650 )?;
10651 require_eq(
10652 &control.worktree_registration(&alias)?.registration_id,
10653 &1,
10654 "concurrently committed registration",
10655 )
10656 }
10657
10658 #[test]
10659 fn unbound_worktree_synchronization_revalidates_git_lifecycle_before_binding()
10660 -> Result<(), Box<dyn Error>> {
10661 let temp = tempfile::tempdir()?;
10662 let primary = temp.path().join("primary");
10663 let linked = temp.path().join("linked");
10664 fs::create_dir(&primary)?;
10665 run_git_fixture(StdCommand::new("git").current_dir(&primary).arg("init"))?;
10666 for (key, value) in [
10667 ("user.name", "ProjectAtlas Test"),
10668 ("user.email", "projectatlas@example.invalid"),
10669 ("commit.gpgsign", "false"),
10670 ("core.autocrlf", "false"),
10671 ] {
10672 run_git_fixture(
10673 StdCommand::new("git")
10674 .current_dir(&primary)
10675 .args(["config", key, value]),
10676 )?;
10677 }
10678 fs::write(primary.join("lib.rs"), "pub fn primary() {}\n")?;
10679 run_git_fixture(
10680 StdCommand::new("git")
10681 .current_dir(&primary)
10682 .args(["add", "."]),
10683 )?;
10684 run_git_fixture(
10685 StdCommand::new("git")
10686 .current_dir(&primary)
10687 .args(["commit", "-m", "fixture"]),
10688 )?;
10689 run_git_fixture(
10690 StdCommand::new("git")
10691 .current_dir(&primary)
10692 .args(["worktree", "add", "-b", "captured"])
10693 .arg(&linked),
10694 )?;
10695
10696 let RepositoryStructure::Git(repository) =
10697 projectatlas_fs::worktree::discover_repository_structure(&primary)?
10698 else {
10699 return Err(io::Error::other("Git fixture was not discovered").into());
10700 };
10701 let root = linked.canonicalize()?;
10702 let entry = repository
10703 .worktrees
10704 .iter()
10705 .find(|entry| match &entry.state {
10706 GitWorktreeState::Active {
10707 root: candidate, ..
10708 } => candidate == &root,
10709 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
10710 })
10711 .ok_or_else(|| io::Error::other("linked fixture was not discovered"))?;
10712 let administrative_directory = entry.administrative_directory.clone();
10713 let administrative_identity = git_administrative_identity(&administrative_directory)?;
10714 let alias = WorktreeAlias::parse("captured")?;
10715 let control_database = primary.join(".projectatlas/projectatlas.db");
10716 fs::create_dir_all(primary.join(".projectatlas"))?;
10717 let control = AtlasStore::open_for_project(&control_database, &primary)?;
10718 let registration = control.register_worktree(
10719 &alias,
10720 &repository.common_directory,
10721 &administrative_directory,
10722 &administrative_identity,
10723 &root,
10724 None,
10725 1,
10726 )?;
10727 require_registered_worktree_lifecycle(®istration, &root)?;
10728
10729 let target_database = root.join(".projectatlas/projectatlas.db");
10730 fs::create_dir_all(root.join(".projectatlas"))?;
10731 let target = AtlasStore::open_for_project(&target_database, &root)?;
10732 target.record_usage(&usage_from_text(
10733 "captured",
10734 "atlas_overview",
10735 None,
10736 None,
10737 "pub fn captured() {}",
10738 "repository overview",
10739 ))?;
10740 let snapshot = target.export_worktree_usage_snapshot()?;
10741 drop(target);
10742
10743 let preserved_target = root.join(".projectatlas-snapshot-race");
10744 let replacement_project = std::cell::Cell::new(None);
10745 let replaced_database = synchronize_registered_worktree_usage_with_catalog_validation(
10746 &control_database,
10747 &primary,
10748 control.project_instance_id()?,
10749 |captured, captured_root| {
10750 require_eq(
10751 &captured.registration_id,
10752 ®istration.registration_id,
10753 "snapshot-race registration",
10754 )
10755 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
10756 if captured_root != root.as_path() {
10757 return Err(CliError::InvalidInput(
10758 "snapshot-race root changed".to_string(),
10759 ));
10760 }
10761 fs::rename(root.join(".projectatlas"), &preserved_target)?;
10762 fs::create_dir(root.join(".projectatlas"))?;
10763 let replacement = AtlasStore::open_for_project(&target_database, &root)?;
10764 replacement_project.set(replacement.project_instance_id()?);
10765 Ok(())
10766 },
10767 || Ok(()),
10768 |_| Ok(()),
10769 );
10770 require_eq(
10771 &replaced_database.as_ref().is_err_and(|error| {
10772 error
10773 .to_string()
10774 .contains("atlas changed after its usage snapshot was captured")
10775 }),
10776 &true,
10777 "same-lifecycle database replacement rejection",
10778 )?;
10779 require_eq(
10780 &(replacement_project.get().is_some()
10781 && replacement_project.get() != Some(snapshot.project_instance_id())),
10782 &true,
10783 "snapshot-race replacement identity",
10784 )?;
10785 let stored = control.worktree_registration(&alias)?;
10786 require_eq(
10787 &(stored.project_instance_id.is_none()
10788 && stored.accepted_telemetry_revision == 0
10789 && control.registered_worktree_token_overview(&alias)?.calls == 0),
10790 &true,
10791 "database replacement rollback state",
10792 )?;
10793 fs::remove_dir_all(root.join(".projectatlas"))?;
10794 fs::rename(&preserved_target, root.join(".projectatlas"))?;
10795
10796 run_git_fixture(
10797 StdCommand::new("git")
10798 .current_dir(&primary)
10799 .args(["worktree", "remove", "--force"])
10800 .arg(&linked),
10801 )?;
10802 run_git_fixture(
10803 StdCommand::new("git")
10804 .current_dir(&primary)
10805 .args(["worktree", "add", "-b", "replacement"])
10806 .arg(&linked),
10807 )?;
10808 fs::create_dir_all(root.join(".projectatlas"))?;
10809 let replacement = AtlasStore::open_for_project(&target_database, &root)?;
10810 let replacement_project = replacement
10811 .project_instance_id()?
10812 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10813 require_eq(
10814 &(replacement_project != snapshot.project_instance_id()),
10815 &true,
10816 "replacement worktree atlas identity",
10817 )?;
10818 drop(replacement);
10819
10820 let rejected = require_registered_worktree_lifecycle(®istration, &root);
10821 require_eq(
10822 &rejected.as_ref().is_err_and(|error| {
10823 error
10824 .to_string()
10825 .contains("administrative lifecycle changed")
10826 }),
10827 &true,
10828 "replacement Git lifecycle rejection",
10829 )?;
10830 let stored = control.worktree_registration(&alias)?;
10831 require_eq(
10832 &stored.project_instance_id,
10833 &None,
10834 "failed revalidation project binding",
10835 )?;
10836 require_eq(
10837 &stored.accepted_telemetry_revision,
10838 &0,
10839 "failed revalidation telemetry revision",
10840 )?;
10841 require_eq(
10842 &control.registered_worktree_token_overview(&alias)?.calls,
10843 &0,
10844 "failed revalidation token totals",
10845 )?;
10846
10847 control.retire_worktree(registration.registration_id, &alias, 2)?;
10848 let RepositoryStructure::Git(replacement_repository) =
10849 projectatlas_fs::worktree::discover_repository_structure(&primary)?
10850 else {
10851 return Err(io::Error::other("replacement Git fixture was not discovered").into());
10852 };
10853 let replacement_entry = replacement_repository
10854 .worktrees
10855 .iter()
10856 .find(|entry| match &entry.state {
10857 GitWorktreeState::Active {
10858 root: candidate, ..
10859 } => candidate == &root,
10860 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => false,
10861 })
10862 .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
10863 let replacement_alias = WorktreeAlias::parse("replacement")?;
10864 let replacement_registration = control.register_worktree(
10865 &replacement_alias,
10866 &replacement_repository.common_directory,
10867 &replacement_entry.administrative_directory,
10868 &git_administrative_identity(&replacement_entry.administrative_directory)?,
10869 &root,
10870 None,
10871 3,
10872 )?;
10873 let preserved_target = root.join(".projectatlas-concurrent-binding");
10874 fs::rename(root.join(".projectatlas"), &preserved_target)?;
10875 let concurrent_binding = synchronize_registered_worktree_usage_with_catalog_validation(
10876 &control_database,
10877 &primary,
10878 control.project_instance_id()?,
10879 |_, _| Ok(()),
10880 || {
10881 fs::rename(&preserved_target, root.join(".projectatlas"))?;
10882 control.bind_worktree_project(
10883 replacement_registration.registration_id,
10884 &replacement_alias,
10885 &root,
10886 replacement_project,
10887 )?;
10888 Ok(())
10889 },
10890 |_| Ok(()),
10891 );
10892 require_eq(
10893 &concurrent_binding.as_ref().is_err_and(|error| {
10894 error
10895 .to_string()
10896 .contains("catalog changed during aggregate synchronization")
10897 }),
10898 &true,
10899 "concurrent unbound-to-bound catalog rejection",
10900 )?;
10901 require_eq(
10902 &control
10903 .worktree_registration(&replacement_alias)?
10904 .project_instance_id,
10905 &Some(replacement_project),
10906 "concurrently committed project binding",
10907 )
10908 }
10909
10910 #[test]
10911 fn worker_pools_respect_work_cardinality_and_runtime_ceiling() {
10912 for (work_items, max_workers, expected) in [
10913 (0, 16, 0),
10914 (1, 16, 1),
10915 (8, 16, 8),
10916 (64, 16, 16),
10917 (64, usize::MAX, INDEX_WORKER_SAFE_CEILING),
10918 (8, 0, 1),
10919 ] {
10920 assert_eq!(
10921 worker_count_for_work(work_items, max_workers),
10922 expected,
10923 "work_items={work_items}, max_workers={max_workers}"
10924 );
10925 }
10926 }
10927
10928 #[test]
10929 fn staged_classifications_prefer_registry_then_bounded_text_evidence() {
10930 let file = |path: &str, language: Option<&str>| Node {
10931 path: path.to_string(),
10932 kind: NodeKind::File,
10933 parent_path: None,
10934 extension: None,
10935 language: language.map(str::to_string),
10936 size_bytes: Some(1),
10937 mtime_ns: Some(1),
10938 content_hash: Some("hash".to_string()),
10939 };
10940 let nodes = vec![
10941 file("docs/guide.md", Some("markdown")),
10942 file("notes", None),
10943 file("blob", None),
10944 ];
10945 let rows = vec![
10946 TextIndexRow {
10947 path: "docs/guide.md".to_string(),
10948 text: None,
10949 reason: TextIndexSkipReason::BinaryOrNonUtf8,
10950 },
10951 TextIndexRow {
10952 path: "notes".to_string(),
10953 text: Some(IndexedFileText {
10954 path: "notes".to_string(),
10955 content_hash: Some("hash".to_string()),
10956 byte_count: 1,
10957 line_count: 1,
10958 content: "x".to_string(),
10959 }),
10960 reason: TextIndexSkipReason::Indexed,
10961 },
10962 TextIndexRow {
10963 path: "blob".to_string(),
10964 text: None,
10965 reason: TextIndexSkipReason::BinaryOrNonUtf8,
10966 },
10967 ];
10968
10969 assert_eq!(
10970 stage_file_content_classifications(&nodes, &rows),
10971 vec![
10972 FileContentClassification {
10973 path: "docs/guide.md".to_string(),
10974 classification:
10975 projectatlas_core::language::ContentClassification::Documentation,
10976 },
10977 FileContentClassification {
10978 path: "notes".to_string(),
10979 classification: projectatlas_core::language::ContentClassification::OtherText,
10980 },
10981 FileContentClassification {
10982 path: "blob".to_string(),
10983 classification: projectatlas_core::language::ContentClassification::Opaque,
10984 },
10985 ]
10986 );
10987 }
10988
10989 #[test]
10990 fn classified_navigation_capability_is_closed_and_directional() {
10991 let report = classified_navigation_capabilities();
10992 assert_eq!(
10993 report.classifications,
10994 [
10995 ContentClassification::Source,
10996 ContentClassification::Documentation,
10997 ContentClassification::ConfigurationData,
10998 ContentClassification::OtherText,
10999 ContentClassification::Opaque,
11000 ]
11001 );
11002 assert_eq!(
11003 report.selections,
11004 [
11005 ContentSelection::Source,
11006 ContentSelection::Documentation,
11007 ContentSelection::Both,
11008 ]
11009 );
11010 assert_eq!(report.document_relation, "extended:documents");
11011 assert_eq!(report.inbound_document_view, "documented_by");
11012 }
11013
11014 #[test]
11015 fn settings_publication_identity_rejects_mixed_snapshots() {
11016 let fingerprint = "a".repeat(64);
11017 let diagnostic = DatabasePublicationReport {
11018 state: IndexPublicationState::Complete,
11019 contract_fingerprint: Some(fingerprint.clone()),
11020 contract_fingerprint_state: DatabasePublicationContractState::Valid,
11021 generation: IndexGeneration::new(4),
11022 };
11023 let matching = IndexPublication {
11024 state: IndexPublicationState::Complete,
11025 contract_fingerprint: Some(fingerprint),
11026 generation: IndexGeneration::new(4),
11027 };
11028 assert!(settings_publication_matches(
11029 Some(&diagnostic),
11030 Some(&matching)
11031 ));
11032
11033 let next_generation = IndexPublication {
11034 generation: IndexGeneration::new(5),
11035 ..matching.clone()
11036 };
11037 assert!(!settings_publication_matches(
11038 Some(&diagnostic),
11039 Some(&next_generation)
11040 ));
11041
11042 let invalid = DatabasePublicationReport {
11043 contract_fingerprint: None,
11044 contract_fingerprint_state: DatabasePublicationContractState::Invalid,
11045 ..diagnostic
11046 };
11047 assert!(!settings_publication_matches(
11048 Some(&invalid),
11049 Some(&matching)
11050 ));
11051 }
11052
11053 #[cfg(unix)]
11054 #[test]
11055 fn settings_report_uses_native_db_identity_when_display_is_unavailable()
11056 -> Result<(), Box<dyn Error>> {
11057 use std::ffi::OsString;
11058 use std::os::unix::ffi::OsStringExt;
11059
11060 let temp = tempfile::tempdir()?;
11061 let raw_name = OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
11062 let root = temp.path().join(&raw_name);
11063 let database = root.join(".projectatlas/projectatlas.db");
11064 fs::create_dir_all(
11065 database
11066 .parent()
11067 .ok_or_else(|| io::Error::other("raw-root database has no parent"))?,
11068 )?;
11069 drop(AtlasStore::open_for_project(&database, &root)?);
11070
11071 let report = build_settings_report(&database, None, OutputFormat::Json)?;
11072 require_eq(
11073 &report.root_detection_source,
11074 &"db".to_string(),
11075 "native database root detection source",
11076 )?;
11077 require_eq(
11078 &report.repo_root,
11079 &None,
11080 "unavailable native database root display",
11081 )?;
11082 let serialized = serde_json::to_string(&report)?;
11083 require_eq(
11084 &serialized.contains('\u{fffd}'),
11085 &false,
11086 "serialized settings fabricated a replacement root",
11087 )?;
11088 Ok(())
11089 }
11090
11091 #[cfg(windows)]
11092 #[test]
11093 fn lossless_native_path_display_preserves_absolute_volume_guid_paths()
11094 -> Result<(), Box<dyn Error>> {
11095 let volume = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\repo\file";
11096 let volume_display = lossless_native_path_display(Path::new(volume))
11097 .ok_or_else(|| io::Error::other("volume-GUID path was not UTF-8"))?;
11098 require_eq(
11099 &volume_display,
11100 &volume.to_string(),
11101 "lossless volume-GUID display",
11102 )?;
11103 require_eq(
11104 &Path::new(&volume_display).is_absolute(),
11105 &true,
11106 "volume-GUID display remained absolute",
11107 )?;
11108
11109 let drive = lossless_native_path_display(Path::new(r"\\?\C:\repo\file"))
11110 .ok_or_else(|| io::Error::other("extended drive path was not UTF-8"))?;
11111 require_eq(
11112 &drive,
11113 &"C:/repo/file".to_string(),
11114 "normalized extended drive display",
11115 )?;
11116 require_eq(
11117 &Path::new(&drive).is_absolute(),
11118 &true,
11119 "extended drive display remained absolute",
11120 )?;
11121
11122 let unc = lossless_native_path_display(Path::new(r"\\?\UNC\server\share\repo\file"))
11123 .ok_or_else(|| io::Error::other("extended UNC path was not UTF-8"))?;
11124 require_eq(
11125 &unc,
11126 &"//server/share/repo/file".to_string(),
11127 "normalized extended UNC display",
11128 )?;
11129 require_eq(
11130 &Path::new(&unc).is_absolute(),
11131 &true,
11132 "extended UNC display remained absolute",
11133 )?;
11134 Ok(())
11135 }
11136
11137 #[cfg(windows)]
11138 #[test]
11139 fn settings_report_preserves_verbatim_native_root_projection() -> Result<(), Box<dyn Error>> {
11140 let temp = tempfile::tempdir()?;
11141 let base = temp
11142 .path()
11143 .to_str()
11144 .ok_or("temporary directory was not UTF-8")?;
11145 let long_component = "a".repeat(220);
11146 let root = PathBuf::from(format!(r"\\?\{base}\{long_component}"));
11147 fs::create_dir(&root)?;
11148 let database = root.join(".projectatlas/projectatlas.db");
11149 fs::create_dir_all(
11150 database
11151 .parent()
11152 .ok_or("verbatim settings database has no parent")?,
11153 )?;
11154 drop(AtlasStore::open_for_project(&database, &root)?);
11155
11156 let config = temp.path().join("verbatim-settings-config.toml");
11157 fs::write(
11158 &config,
11159 format!(
11160 "[project]\nroot = {}\n",
11161 serde_json::to_string(
11162 &root
11163 .to_str()
11164 .ok_or("verbatim root was not UTF-8")?
11165 .to_owned()
11166 )?
11167 ),
11168 )?;
11169 let expected = CanonicalProjectRoot::from_path(&root)?.display_string()?;
11170 if !expected.starts_with(r"\\?\") {
11171 return Err("verbatim root lost its extended prefix".into());
11172 }
11173
11174 let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
11175 require_eq(
11176 &report.repo_root,
11177 &Some(expected.clone()),
11178 "JSON settings verbatim root",
11179 )?;
11180 let json = serde_json::to_value(&report)?;
11181 require_eq(
11182 &json.get("repo_root"),
11183 &Some(&Value::String(expected.clone())),
11184 "JSON settings verbatim root field",
11185 )?;
11186 let toon = crate::render_settings_report(&report);
11187 let toon_value: Value = toon_format::decode_default(&toon)?;
11188 require_eq(
11189 &toon_value.pointer("/settings/repo_root"),
11190 &Some(&Value::String(expected)),
11191 "TOON settings verbatim root field",
11192 )?;
11193 Ok(())
11194 }
11195
11196 #[cfg(windows)]
11197 #[test]
11198 fn settings_report_accepts_case_only_root_rename() -> Result<(), Box<dyn Error>> {
11199 let temp = tempfile::tempdir()?;
11200 let original = temp.path().join("CaseOnlyRoot");
11201 let staging = temp.path().join("CaseOnlyRootStaging");
11202 let renamed = temp.path().join("caseonlyroot");
11203 fs::create_dir(&original)?;
11204 let database = original.join(".projectatlas/projectatlas.db");
11205 fs::create_dir_all(
11206 database
11207 .parent()
11208 .ok_or_else(|| io::Error::other("case-only database has no parent"))?,
11209 )?;
11210 drop(AtlasStore::open_for_project(&database, &original)?);
11211
11212 fs::rename(&original, &staging)?;
11213 fs::rename(&staging, &renamed)?;
11214 let renamed_database = renamed.join(".projectatlas/projectatlas.db");
11215 let config = temp.path().join("case-only-config.toml");
11216 fs::write(
11217 &config,
11218 format!(
11219 "[project]\nroot = {}\n",
11220 serde_json::to_string(&renamed.to_string_lossy())?
11221 ),
11222 )?;
11223
11224 let report = build_settings_report(&renamed_database, Some(&config), OutputFormat::Json)?;
11225 require_eq(
11226 &report.root_verified,
11227 &true,
11228 "settings accepted case-only root rename",
11229 )?;
11230 require_eq(
11231 &report.root_mismatches.is_empty(),
11232 &true,
11233 "case-only root rename mismatch diagnostics",
11234 )?;
11235 let json = serde_json::to_value(&report)?;
11236 require_eq(
11237 &json.get("root_verified"),
11238 &Some(&Value::Bool(true)),
11239 "JSON settings root verification",
11240 )?;
11241 let toon = crate::render_settings_report(&report);
11242 let toon_value: Value = toon_format::decode_default(&toon)?;
11243 require_eq(
11244 &toon_value.pointer("/settings/root_verified"),
11245 &Some(&Value::Bool(true)),
11246 "TOON settings root verification",
11247 )?;
11248 Ok(())
11249 }
11250
11251 #[cfg(windows)]
11252 #[test]
11253 fn settings_report_rejects_case_sensitive_sibling() -> Result<(), Box<dyn Error>> {
11254 let temp = tempfile::tempdir()?;
11255 let parent = temp.path().join("case-sensitive-parent");
11256 fs::create_dir(&parent)?;
11257 let enabled = StdCommand::new("fsutil")
11258 .args(["file", "SetCaseSensitiveInfo"])
11259 .arg(&parent)
11260 .arg("enable")
11261 .status()
11262 .is_ok_and(|status| status.success());
11263 if !enabled {
11264 return Ok(());
11265 }
11266
11267 let stored_root = parent.join("Repo");
11268 let selected_root = parent.join("repo");
11269 fs::create_dir(&stored_root)?;
11270 fs::create_dir(&selected_root)?;
11271 let database = stored_root.join(".projectatlas/projectatlas.db");
11272 fs::create_dir_all(
11273 database
11274 .parent()
11275 .ok_or_else(|| io::Error::other("case-sensitive database has no parent"))?,
11276 )?;
11277 drop(AtlasStore::open_for_project(&database, &stored_root)?);
11278 let config = temp.path().join("case-sensitive-config.toml");
11279 fs::write(
11280 &config,
11281 format!(
11282 "[project]\nroot = {}\n",
11283 serde_json::to_string(&selected_root.to_string_lossy())?
11284 ),
11285 )?;
11286
11287 let report = build_settings_report(&database, Some(&config), OutputFormat::Json)?;
11288 require_eq(
11289 &report.root_verified,
11290 &false,
11291 "settings rejected case-sensitive sibling",
11292 )?;
11293 require_eq(
11294 &report.root_mismatches.is_empty(),
11295 &false,
11296 "case-sensitive sibling mismatch diagnostics",
11297 )?;
11298 let json = serde_json::to_value(&report)?;
11299 require_eq(
11300 &json.get("root_verified"),
11301 &Some(&Value::Bool(false)),
11302 "JSON case-sensitive sibling verification",
11303 )?;
11304 let toon = crate::render_settings_report(&report);
11305 let toon_value: Value = toon_format::decode_default(&toon)?;
11306 require_eq(
11307 &toon_value.pointer("/settings/root_verified"),
11308 &Some(&Value::Bool(false)),
11309 "TOON case-sensitive sibling verification",
11310 )?;
11311 Ok(())
11312 }
11313
11314 #[cfg(windows)]
11315 #[test]
11316 fn freshness_and_symbol_build_revalidate_case_only_root_without_sibling_mutation()
11317 -> Result<(), Box<dyn Error>> {
11318 let temp = tempfile::tempdir()?;
11319 let original = temp.path().join("CaseOnlyRuntimeRoot");
11320 let staging = temp.path().join("CaseOnlyRuntimeRootStaging");
11321 let renamed = temp.path().join("caseonlyruntimeroot");
11322 fs::create_dir(&original)?;
11323 fs::write(original.join("lib.rs"), "pub fn runtime_root() {}\n")?;
11324 let database = original.join(".projectatlas/projectatlas.db");
11325 fs::create_dir_all(
11326 database
11327 .parent()
11328 .ok_or_else(|| io::Error::other("case-only runtime database has no parent"))?,
11329 )?;
11330 let original_plan = ScanRuntimePlan::for_path(None, &original, None)?;
11331 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11332 let mut original_store = open_atlas_store_for_project(&database, &original)?;
11333 run_scan_pipeline(&mut original_store, &original_plan, &symbol_options)?;
11334 let initial_project = original_store
11335 .project_instance_id()?
11336 .ok_or_else(|| io::Error::other("case-only runtime project identity is missing"))?;
11337 drop(original_store);
11338
11339 fs::rename(&original, &staging)?;
11340 fs::rename(&staging, &renamed)?;
11341 let renamed_database = renamed.join(".projectatlas/projectatlas.db");
11342 let fresh = open_fresh_atlas_store_for_project(&renamed_database, &renamed, None)?;
11343 require_eq(
11344 &fresh.project_instance_id()?,
11345 &Some(initial_project),
11346 "fresh read after case-only root rename",
11347 )?;
11348 drop(fresh);
11349
11350 let renamed_plan = ScanRuntimePlan::for_path(None, &renamed, None)?;
11351 let mut renamed_store = open_atlas_store_for_project(&renamed_database, &renamed)?;
11352 run_symbol_build_pipeline(&mut renamed_store, &renamed_plan, &symbol_options, None)?;
11353 require_eq(
11354 &renamed_store.project_instance_id()?,
11355 &Some(initial_project),
11356 "symbol build after case-only root rename",
11357 )?;
11358 drop(renamed_store);
11359
11360 let case_sensitive_parent = temp.path().join("runtime-case-sensitive-parent");
11361 fs::create_dir(&case_sensitive_parent)?;
11362 let enabled = StdCommand::new("fsutil")
11363 .args(["file", "SetCaseSensitiveInfo"])
11364 .arg(&case_sensitive_parent)
11365 .arg("enable")
11366 .status()
11367 .is_ok_and(|status| status.success());
11368 if !enabled {
11369 return Ok(());
11370 }
11371 let stored_root = case_sensitive_parent.join("Repo");
11372 let selected_root = case_sensitive_parent.join("repo");
11373 fs::create_dir(&stored_root)?;
11374 fs::create_dir(&selected_root)?;
11375 fs::write(stored_root.join("lib.rs"), "pub fn stored_root() {}\n")?;
11376 let sibling_database = stored_root.join(".projectatlas/projectatlas.db");
11377 fs::create_dir_all(
11378 sibling_database
11379 .parent()
11380 .ok_or_else(|| io::Error::other("case-sensitive runtime database has no parent"))?,
11381 )?;
11382 let sibling_plan = ScanRuntimePlan::for_path(None, &stored_root, None)?;
11383 let mut sibling_store = open_atlas_store_for_project(&sibling_database, &stored_root)?;
11384 run_scan_pipeline(&mut sibling_store, &sibling_plan, &symbol_options)?;
11385 drop(sibling_store);
11386
11387 let sidecar_bytes = |path: &Path| -> Result<Vec<Option<Vec<u8>>>, Box<dyn Error>> {
11388 [
11389 path.to_path_buf(),
11390 db_sidecar_path(path, "wal"),
11391 db_sidecar_path(path, "shm"),
11392 db_sidecar_path(path, "journal"),
11393 ]
11394 .into_iter()
11395 .map(|candidate| match fs::read(candidate) {
11396 Ok(bytes) => Ok(Some(bytes)),
11397 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
11398 Err(error) => Err(error.into()),
11399 })
11400 .collect()
11401 };
11402 let durable_state = |path: &Path| -> Result<
11403 (
11404 Option<CanonicalProjectRoot>,
11405 Option<ProjectInstanceId>,
11406 Option<IndexPublication>,
11407 ),
11408 Box<dyn Error>,
11409 > {
11410 let store = AtlasStore::open_read_only(path)?;
11411 Ok((
11412 store.project_root_identity()?,
11413 store.project_instance_id()?,
11414 store.index_publication()?,
11415 ))
11416 };
11417
11418 drop(AtlasStore::open_read_only(&sibling_database)?);
11419 let before_sidecars = sidecar_bytes(&sibling_database)?;
11420 let before_state = durable_state(&sibling_database)?;
11421 let Err(CliError::ProjectMismatch(_)) =
11422 open_fresh_atlas_store_for_project(&sibling_database, &selected_root, None)
11423 else {
11424 return Err(io::Error::other("case-sensitive sibling was admitted").into());
11425 };
11426 let after_sidecars = sidecar_bytes(&sibling_database)?;
11427 let after_state = durable_state(&sibling_database)?;
11428 require_eq(
11429 &after_sidecars,
11430 &before_sidecars,
11431 "case-sensitive sibling sidecar state",
11432 )?;
11433 require_eq(
11434 &after_state,
11435 &before_state,
11436 "case-sensitive sibling durable state",
11437 )?;
11438 Ok(())
11439 }
11440
11441 #[test]
11442 fn registered_embedded_hosts_admit_native_symbol_work() {
11443 for capability in projectatlas_core::language::language_documentation_rows()
11444 .iter()
11445 .filter(|capability| capability.embedded_language.is_some())
11446 {
11447 assert!(
11448 is_symbol_candidate("selected.host", Some(capability.id)),
11449 "{}",
11450 capability.id
11451 );
11452 }
11453 for path in [
11454 "page.html",
11455 "page.htm",
11456 "page.HTML",
11457 "View.vue",
11458 "View.svelte",
11459 ] {
11460 assert!(is_symbol_candidate(path, None), "{path}");
11461 assert!(
11462 !is_symbol_candidate(path, Some("toon")),
11463 "explicit language overrides host extension"
11464 );
11465 }
11466 assert!(!is_symbol_candidate("page.css", Some("css")));
11467 assert!(!is_symbol_candidate("page.xml", Some("xml")));
11468 }
11469
11470 #[test]
11471 fn supplied_language_controls_symbol_candidate_owner() {
11472 for path in ["Cargo.toml", "src/App.vue", "scripts/Get-Atlas.ps1"] {
11473 assert!(!is_symbol_candidate(path, Some("toon")), "{path}");
11474 }
11475 assert!(is_symbol_candidate("data/report.toon", Some("rust")));
11476 assert!(is_symbol_candidate(
11477 "data/report.toon",
11478 Some("cargo-manifest")
11479 ));
11480 assert!(is_symbol_candidate("data/report.toon", Some("vue")));
11481 assert!(is_symbol_candidate("data/report.toon", Some("powershell")));
11482 assert!(!is_symbol_candidate("data/report.toon", Some("toon")));
11483 }
11484
11485 #[test]
11486 fn optional_catalog_symbol_work_requires_effective_admission() {
11487 assert!(is_symbol_candidate("scripts/report.awk", Some("awk")));
11488 assert!(!is_symbol_candidate_for_admission(
11489 "scripts/report.awk",
11490 Some("awk"),
11491 false,
11492 ));
11493 assert!(is_symbol_candidate_for_admission(
11494 "scripts/report.awk",
11495 Some("awk"),
11496 true,
11497 ));
11498 assert!(is_symbol_candidate_for_admission(
11499 "src/lib.rs",
11500 Some("rust"),
11501 false,
11502 ));
11503 }
11504
11505 #[test]
11506 fn missing_language_preserves_specialized_symbol_candidate_inference() {
11507 for path in [
11508 "Cargo.toml",
11509 "Cargo.lock",
11510 "src/App.vue",
11511 "src/App.VUE",
11512 "scripts/Get-Atlas.ps1",
11513 "scripts/Atlas.psm1",
11514 "scripts/Atlas.psd1",
11515 ] {
11516 assert!(is_symbol_candidate(path, None), "{path}");
11517 }
11518 assert!(!is_symbol_candidate("data/report.toon", None));
11519 }
11520
11521 #[test]
11522 fn rejected_database_location_does_not_create_or_change_database() -> Result<(), Box<dyn Error>>
11523 {
11524 let temp = tempfile::tempdir()?;
11525 let root = temp.path().join("repository");
11526 fs::create_dir_all(&root)?;
11527
11528 for uncertain in [false, true] {
11529 let database_parent = temp
11530 .path()
11531 .join(if uncertain {
11532 "uncertain"
11533 } else {
11534 "unsupported"
11535 })
11536 .join("nested");
11537 let database = database_parent.join("projectatlas.db");
11538 let rejected_path = database.clone();
11539 let result = open_atlas_store_for_project_with_location_validator(
11540 &database,
11541 &root,
11542 move |_path| {
11543 if uncertain {
11544 Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
11545 path: rejected_path,
11546 mount_point: None,
11547 filesystem_type: None,
11548 reason: "injected filesystem uncertainty".to_string(),
11549 })
11550 } else {
11551 Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
11552 path: rejected_path,
11553 mount_point: None,
11554 filesystem_type: Some("nfs".to_string()),
11555 })
11556 }
11557 },
11558 );
11559 let rejected = matches!(
11560 result,
11561 Err(CliError::Db(
11562 projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
11563 | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
11564 ))
11565 );
11566 require_eq(&rejected, &true, "typed location rejection")?;
11567 require_eq(
11568 &database_parent.exists(),
11569 &false,
11570 "rejected database parent absence",
11571 )?;
11572 require_eq(&database.exists(), &false, "rejected database absence")?;
11573
11574 let existing_database = temp.path().join(if uncertain {
11575 "uncertain-existing.db"
11576 } else {
11577 "unsupported-existing.db"
11578 });
11579 let original_bytes = b"existing database bytes stay untouched";
11580 fs::write(&existing_database, original_bytes)?;
11581 let rejected_path = existing_database.clone();
11582 let existing_result = open_atlas_store_for_project_with_location_validator(
11583 &existing_database,
11584 &root,
11585 move |_path| {
11586 if uncertain {
11587 Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
11588 path: rejected_path,
11589 mount_point: None,
11590 filesystem_type: None,
11591 reason: "injected filesystem uncertainty".to_string(),
11592 })
11593 } else {
11594 Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
11595 path: rejected_path,
11596 mount_point: None,
11597 filesystem_type: Some("nfs".to_string()),
11598 })
11599 }
11600 },
11601 );
11602 require_eq(
11603 &matches!(
11604 existing_result,
11605 Err(CliError::Db(
11606 projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
11607 | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
11608 ))
11609 ),
11610 &true,
11611 "typed existing location rejection",
11612 )?;
11613 require_eq(
11614 &fs::read(&existing_database)?,
11615 &original_bytes.to_vec(),
11616 "rejected existing database bytes",
11617 )?;
11618 }
11619 Ok(())
11620 }
11621
11622 #[test]
11623 fn operation_deadline_preserves_default_ceiling_and_fail_closed_bounds() {
11624 for (timeout_seconds, expected) in [
11625 (None, DEFAULT_INDEX_WORK_TIMEOUT),
11626 (Some(0), Duration::ZERO),
11627 (Some(1), Duration::from_secs(1)),
11628 (
11629 Some(DEFAULT_INDEX_WORK_TIMEOUT.as_secs() + 1),
11630 DEFAULT_INDEX_WORK_TIMEOUT,
11631 ),
11632 (Some(u64::MAX), Duration::ZERO),
11633 ] {
11634 let options = SymbolBuildOptions::new(1_024, Some(1), timeout_seconds);
11635 let control = index_work_control(&options);
11636 assert_eq!(
11637 control
11638 .deadline()
11639 .map(|deadline| deadline.duration_since(control.started_at())),
11640 Some(expected)
11641 );
11642 }
11643 }
11644
11645 #[test]
11646 fn oversized_source_delta_retains_exact_counts_without_partial_refresh_paths()
11647 -> Result<(), Box<dyn Error>> {
11648 let temp = tempfile::tempdir()?;
11649 let mut delta = SourceDelta::default();
11650 for index in 0..=MAX_INCREMENTAL_CHANGED_PATHS {
11651 delta.record(
11652 Some(&Node {
11653 path: format!("file_{index:08}.rs"),
11654 kind: NodeKind::File,
11655 parent_path: None,
11656 extension: None,
11657 language: None,
11658 size_bytes: Some(1),
11659 mtime_ns: None,
11660 content_hash: None,
11661 }),
11662 None,
11663 );
11664 }
11665 require_eq(
11666 &delta.paths.len(),
11667 &MAX_INCREMENTAL_CHANGED_PATHS,
11668 "bounded retained paths",
11669 )?;
11670 let plan = delta
11671 .finish(temp.path())
11672 .ok_or_else(|| io::Error::other("oversized change set disappeared"))?;
11673 require_eq(
11674 &plan.report.changed,
11675 &(MAX_INCREMENTAL_CHANGED_PATHS + 1),
11676 "complete change count",
11677 )?;
11678 require_eq(
11679 &plan.report.added,
11680 &plan.report.changed,
11681 "complete addition count",
11682 )?;
11683 require_eq(
11684 &plan.report.sample_paths.len(),
11685 &INDEX_FRESHNESS_SAMPLE_LIMIT,
11686 "bounded ordered sample",
11687 )?;
11688 require_eq(
11689 &plan.paths_complete,
11690 &false,
11691 "incomplete path retention is explicit",
11692 )?;
11693 require_eq(
11694 &plan.requires_full_scan,
11695 &true,
11696 "complete discovery selected",
11697 )?;
11698 require_eq(
11699 &plan.paths.is_empty(),
11700 &true,
11701 "partial delta cannot be used for publication",
11702 )?;
11703 Ok(())
11704 }
11705
11706 #[test]
11707 fn normal_read_refresh_delta_enforces_path_and_byte_budgets() -> Result<(), Box<dyn Error>> {
11708 let temp = tempfile::tempdir()?;
11709 let node = |path: String, size_bytes: u64| Node {
11710 path,
11711 kind: NodeKind::File,
11712 parent_path: None,
11713 extension: Some(".rs".to_string()),
11714 language: Some("rust".to_string()),
11715 size_bytes: Some(size_bytes),
11716 mtime_ns: Some(1),
11717 content_hash: Some("current-content".to_string()),
11718 };
11719 let path_bounded_nodes = (0..=NORMAL_READ_REFRESH_MAX_PATHS)
11720 .map(|index| node(format!("src/file_{index}.rs"), 1))
11721 .collect::<Vec<_>>();
11722 let path_delta = source_node_delta(temp.path(), &path_bounded_nodes, &[])
11723 .ok_or_else(|| io::Error::other("path-bounded freshness delta was missing"))?;
11724 require_eq(
11725 &path_delta.report.scope,
11726 &IndexRefreshScope::Full,
11727 "path-bounded refresh scope",
11728 )?;
11729 require_eq(
11730 &path_delta.report.changed,
11731 &(NORMAL_READ_REFRESH_MAX_PATHS + 1),
11732 "path-bounded change count",
11733 )?;
11734 require_eq(
11735 &path_delta.report.sample_paths.len(),
11736 &INDEX_FRESHNESS_SAMPLE_LIMIT,
11737 "bounded freshness sample",
11738 )?;
11739
11740 let byte_bounded_nodes = vec![node(
11741 "src/large.rs".to_string(),
11742 NORMAL_READ_REFRESH_MAX_BYTES + 1,
11743 )];
11744 let byte_delta = source_node_delta(temp.path(), &byte_bounded_nodes, &[])
11745 .ok_or_else(|| io::Error::other("byte-bounded freshness delta was missing"))?;
11746 require_eq(
11747 &byte_delta.report.scope,
11748 &IndexRefreshScope::Full,
11749 "byte-bounded refresh scope",
11750 )?;
11751 Ok(())
11752 }
11753
11754 #[test]
11755 fn controlled_freshness_plan_uses_the_callers_cancellation() -> Result<(), Box<dyn Error>> {
11756 let temp = tempfile::tempdir()?;
11757 let root = temp.path().join("repo");
11758 fs::create_dir(&root)?;
11759 fs::write(root.join("lib.rs"), "pub fn indexed() {}\n")?;
11760 let db_path = root.join(".projectatlas").join("projectatlas.db");
11761 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
11762 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11763 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
11764 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
11765 drop(store);
11766
11767 let invalid_config = root.join("invalid-config.toml");
11768 fs::write(&invalid_config, "[invalid")?;
11769 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11770 control.cancel();
11771 let result = open_fresh_atlas_store_for_project_controlled(
11772 &db_path,
11773 &root,
11774 Some(&invalid_config),
11775 &control,
11776 );
11777 if !matches!(
11778 result,
11779 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
11780 stage: IndexWorkStage::Publication,
11781 }))
11782 ) {
11783 return Err(io::Error::other(
11784 "controlled freshness parsed policy outside the caller cancellation boundary",
11785 )
11786 .into());
11787 }
11788 Ok(())
11789 }
11790
11791 #[test]
11792 fn automatic_read_refresh_returns_typed_state_when_writer_is_unavailable()
11793 -> Result<(), Box<dyn Error>> {
11794 let temp = tempfile::tempdir()?;
11795 let root = temp.path().join("repo");
11796 fs::create_dir(&root)?;
11797 let source_path = root.join("lib.rs");
11798 let indexed_source = "pub fn indexed() {}\n";
11799 let current_source = "pub fn current() {}\n";
11800 fs::write(&source_path, indexed_source)?;
11801 let db_path = root.join(".projectatlas").join("projectatlas.db");
11802 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
11803 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
11804 let mut initial_store = open_atlas_store_for_project(&db_path, &plan.root)?;
11805 run_scan_pipeline(&mut initial_store, &plan, &symbol_options)?;
11806 let initial_generation = initial_store
11807 .index_publication()?
11808 .ok_or_else(|| io::Error::other("initial publication missing"))?
11809 .generation;
11810 drop(initial_store);
11811
11812 fs::write(&source_path, current_source)?;
11813 let mut blocking_writer = open_atlas_store_for_project(&db_path, &plan.root)?;
11814 let publication =
11815 blocking_writer.begin_index_publication(&plan.publication_contract_fingerprint())?;
11816 let refresh = open_fresh_atlas_store_for_project(&db_path, &plan.root, None);
11817 let Err(CliError::RefreshRequired(report)) = refresh else {
11818 return Err(io::Error::other(
11819 "contended automatic refresh did not return typed refresh_required",
11820 )
11821 .into());
11822 };
11823 require_eq(
11824 &report.scope,
11825 &IndexRefreshScope::Incremental,
11826 "contended automatic refresh scope",
11827 )?;
11828 require_eq(
11829 &report.reason,
11830 &IndexRefreshReason::SourceChanged,
11831 "contended automatic refresh reason",
11832 )?;
11833
11834 let last_valid = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
11835 require_eq(
11836 &last_valid
11837 .index_publication()?
11838 .ok_or_else(|| io::Error::other("last-valid publication missing"))?
11839 .generation,
11840 &initial_generation,
11841 "last-valid generation during contention",
11842 )?;
11843 require_eq(
11844 &last_valid
11845 .load_file_text("lib.rs")?
11846 .ok_or_else(|| io::Error::other("last-valid source text missing"))?
11847 .content,
11848 &indexed_source.to_string(),
11849 "last-valid source during contention",
11850 )?;
11851 drop(last_valid);
11852 drop(publication);
11853
11854 let repaired = open_fresh_atlas_store_for_project(&db_path, &plan.root, None)?;
11855 require_eq(
11856 &repaired
11857 .index_publication()?
11858 .ok_or_else(|| io::Error::other("repaired publication missing"))?
11859 .generation,
11860 &initial_generation
11861 .checked_next()
11862 .ok_or_else(|| io::Error::other("test generation overflow"))?,
11863 "repaired generation after contention",
11864 )?;
11865 require_eq(
11866 &repaired
11867 .load_file_text("lib.rs")?
11868 .ok_or_else(|| io::Error::other("repaired source text missing"))?
11869 .content,
11870 ¤t_source.to_string(),
11871 "current source after contention",
11872 )?;
11873 Ok(())
11874 }
11875
11876 #[test]
11877 fn fallback_retains_actual_source_provenance() -> Result<(), Box<dyn Error>> {
11878 for (language, content, parser) in [
11879 ("rust", "fn recovered() {}", ParserKind::TreeSitter),
11880 ("rust", "def recovered(): pass", ParserKind::Fallback),
11881 (
11882 "php",
11883 "<?php function recovered() {}",
11884 ParserKind::TreeSitter,
11885 ),
11886 ("php", "<?php\ndef recovered(): pass", ParserKind::Fallback),
11887 ] {
11888 let job = SymbolParseJob {
11889 path: format!("src/recovered.{language}"),
11890 native_path: PathBuf::new(),
11891 expected_content_hash: String::new(),
11892 language: Some(language.to_string()),
11893 fallback_summary: None,
11894 purpose_needs_suggestion: false,
11895 };
11896 let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
11897 &job,
11898 content,
11899 None,
11900 &SymbolBuildOptions::new(1_024, Some(1), None),
11901 &standalone_index_work_control(),
11902 ) else {
11903 return Err(io::Error::other("source provenance fixture did not parse").into());
11904 };
11905 require_eq(&parsed.graph.parser, &parser, "observed fact parser")?;
11906 require_eq(&parsed.source_parser, &parser, "observed source parser")?;
11907 require_eq(
11908 &parsed
11909 .graph
11910 .symbols
11911 .iter()
11912 .any(|symbol| symbol.name == "recovered"),
11913 &true,
11914 "recovered declaration",
11915 )?;
11916 }
11917 Ok(())
11918 }
11919
11920 #[test]
11921 fn partial_php_facts_keep_tree_sitter_source_provenance() -> Result<(), Box<dyn Error>> {
11922 let content = "<?php function run(): void { $callable(); helper(); }";
11923 let job = SymbolParseJob {
11924 path: "src/dynamic.php".to_string(),
11925 native_path: PathBuf::new(),
11926 expected_content_hash: String::new(),
11927 language: Some("php".to_string()),
11928 fallback_summary: None,
11929 purpose_needs_suggestion: false,
11930 };
11931 let SymbolParseOutcome::Parsed(parsed) = parse_admitted_symbol_job(
11932 &job,
11933 content,
11934 None,
11935 &SymbolBuildOptions::new(1_024, Some(1), None),
11936 &standalone_index_work_control(),
11937 ) else {
11938 return Err(io::Error::other("partial PHP fixture did not parse").into());
11939 };
11940 require_eq(
11941 &parsed.graph.parser,
11942 &ParserKind::Fallback,
11943 "partial PHP fact parser",
11944 )?;
11945 require_eq(
11946 &parsed.source_parser,
11947 &ParserKind::TreeSitter,
11948 "partial PHP source parser",
11949 )?;
11950 require_eq(
11951 &parsed
11952 .graph
11953 .symbols
11954 .iter()
11955 .any(|symbol| symbol.name == "run"),
11956 &true,
11957 "partial PHP recovered symbol",
11958 )?;
11959 require_eq(
11960 &parsed.graph.relations.iter().any(|relation| {
11961 relation.kind == RelationKind::Calls && relation.target_name == "helper"
11962 }),
11963 &true,
11964 "partial PHP known call",
11965 )?;
11966 require_eq(
11967 &parsed.graph.relations.iter().all(|relation| {
11968 relation.kind != RelationKind::Calls || relation.target_name != "$callable"
11969 }),
11970 &true,
11971 "partial PHP dynamic call abstention",
11972 )?;
11973 Ok(())
11974 }
11975
11976 #[test]
11977 fn staged_plain_text_preserves_parser_bytes_and_avoids_a_second_read()
11978 -> Result<(), Box<dyn Error>> {
11979 let temp = tempfile::tempdir()?;
11980 let options = SymbolBuildOptions::new(4_096, Some(1), None);
11981 let control = standalone_index_work_control();
11982 for (path, language, content) in [
11983 (
11984 "lib.rs",
11985 "rust",
11986 "\u{feff}// Comment\r\npub fn entry() { helper(); }\r\nfn helper() {}\r\n",
11987 ),
11988 (
11989 "guide.md",
11990 "markdown",
11991 "\u{feff}# Guide\r\n\r\n[Library](lib.rs)\r\n",
11992 ),
11993 ] {
11994 let native_path = temp.path().join(path);
11995 fs::write(&native_path, content)?;
11996 let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
11997 let node = Node {
11998 path: path.to_string(),
11999 kind: NodeKind::File,
12000 parent_path: None,
12001 extension: None,
12002 language: Some(language.to_string()),
12003 size_bytes: Some(content.len() as u64),
12004 mtime_ns: None,
12005 content_hash: Some(hash.clone()),
12006 };
12007 let rows =
12008 indexed_file_texts_for_nodes(temp.path(), &[node], TextIndexOptions::new(4_096))?;
12009 let text = rows
12010 .first()
12011 .and_then(|row| row.text.as_ref())
12012 .ok_or_else(|| io::Error::other("verified staged text missing"))?;
12013 require_eq(
12014 &text.content.as_str(),
12015 &content,
12016 "raw BOM and CRLF bytes preserved",
12017 )?;
12018 let job = SymbolParseJob {
12019 path: path.to_string(),
12020 native_path: native_path.clone(),
12021 expected_content_hash: hash,
12022 language: Some(language.to_string()),
12023 fallback_summary: None,
12024 purpose_needs_suggestion: false,
12025 };
12026 let SymbolParseOutcome::Parsed(expected) =
12027 parse_symbol_job_controlled(&job, &options, &control)
12028 else {
12029 return Err(io::Error::other("original source did not parse").into());
12030 };
12031 fs::remove_file(&native_path)?;
12032 let SymbolParseOutcome::Parsed(actual) =
12033 parse_symbol_job_with_staged_text_controlled(&job, &options, &control, Some(text))
12034 else {
12035 return Err(
12036 io::Error::other("verified text was reopened instead of borrowed").into(),
12037 );
12038 };
12039 require_eq(
12040 &serde_json::to_value(&actual.graph)?,
12041 &serde_json::to_value(&expected.graph)?,
12042 "borrowed and independently read parser facts",
12043 )?;
12044 require_eq(
12045 &actual.summary,
12046 &expected.summary,
12047 "borrowed source summary",
12048 )?;
12049 require_eq(
12050 &actual.source_parser,
12051 &expected.source_parser,
12052 "borrowed parser provenance",
12053 )?;
12054 let smaller = SymbolBuildOptions::new((content.len() - 1) as u64, Some(1), None);
12055 require_eq(
12056 &matches!(
12057 parse_symbol_job_with_staged_text_controlled(
12058 &job,
12059 &smaller,
12060 &control,
12061 Some(text)
12062 ),
12063 SymbolParseOutcome::Io { .. }
12064 ),
12065 &true,
12066 "different parser byte cap uses bounded read",
12067 )?;
12068 let mut mismatched = text.clone();
12069 mismatched.content_hash = Some("different".to_string());
12070 require_eq(
12071 &matches!(
12072 parse_symbol_job_with_staged_text_controlled(
12073 &job,
12074 &options,
12075 &control,
12076 Some(&mismatched)
12077 ),
12078 SymbolParseOutcome::Io { .. }
12079 ),
12080 &true,
12081 "unmatched source hash uses independent read",
12082 )?;
12083 let mut document = job;
12084 document.path = "document.pdf".to_string();
12085 document.language = Some("pdf".to_string());
12086 mismatched.path = document.path.clone();
12087 mismatched.content_hash = Some(document.expected_content_hash.clone());
12088 require_eq(
12089 &matches!(
12090 parse_symbol_job_with_staged_text_controlled(
12091 &document,
12092 &options,
12093 &control,
12094 Some(&mismatched)
12095 ),
12096 SymbolParseOutcome::Io { .. }
12097 ),
12098 &true,
12099 "extracted document text never replaces source bytes",
12100 )?;
12101 }
12102 Ok(())
12103 }
12104
12105 #[test]
12106 fn derived_source_readers_reject_bytes_outside_staged_hash() -> Result<(), Box<dyn Error>> {
12107 let temp = tempfile::tempdir()?;
12108 let path = temp.path().join("lib.rs");
12109 let staged_source = "fn first() {}\n";
12110 let changed_source = "fn other() {}\n";
12111 fs::write(&path, staged_source)?;
12112 let expected_content_hash = blake3::hash(staged_source.as_bytes()).to_hex().to_string();
12113 let node = Node {
12114 path: "lib.rs".to_string(),
12115 kind: NodeKind::File,
12116 parent_path: None,
12117 extension: Some(".rs".to_string()),
12118 language: Some("rust".to_string()),
12119 size_bytes: Some(staged_source.len() as u64),
12120 mtime_ns: Some(1),
12121 content_hash: Some(expected_content_hash.clone()),
12122 };
12123 fs::write(&path, changed_source)?;
12124
12125 let text_result = indexed_file_texts_for_nodes(
12126 temp.path(),
12127 std::slice::from_ref(&node),
12128 TextIndexOptions::new(1_024),
12129 );
12130 let Err(CliError::RefreshRequired(details)) = text_result else {
12131 return Err(io::Error::other("text derivation accepted changed source bytes").into());
12132 };
12133 require_eq(
12134 &details.reason,
12135 &IndexRefreshReason::SourceChanged,
12136 "text source-change reason",
12137 )?;
12138 require_eq(
12139 &details.sample_paths,
12140 &vec!["lib.rs".to_string()],
12141 "text source-change paths",
12142 )?;
12143
12144 let symbol_outcome = parse_symbol_job(
12145 &SymbolParseJob {
12146 path: node.path,
12147 native_path: path,
12148 expected_content_hash,
12149 language: node.language,
12150 fallback_summary: None,
12151 purpose_needs_suggestion: false,
12152 },
12153 &SymbolBuildOptions::new(1_024, Some(1), None),
12154 Instant::now(),
12155 );
12156 if !matches!(
12157 symbol_outcome,
12158 SymbolParseOutcome::SourceChanged { path } if path == "lib.rs"
12159 ) {
12160 return Err(io::Error::other("symbol derivation accepted changed source bytes").into());
12161 }
12162
12163 let bounded_path = temp.path().join("bounded.txt");
12164 fs::write(&bounded_path, "four")?;
12165 let bounded_node = Node {
12166 path: "bounded.txt".to_string(),
12167 kind: NodeKind::File,
12168 parent_path: None,
12169 extension: Some(".txt".to_string()),
12170 language: Some("text".to_string()),
12171 size_bytes: Some(4),
12172 mtime_ns: Some(1),
12173 content_hash: Some(blake3::hash(b"four").to_hex().to_string()),
12174 };
12175 let bounded_control = standalone_index_work_control();
12176 let bounded_result = indexed_file_texts_for_nodes_with_limit(
12177 temp.path(),
12178 &[bounded_node],
12179 TextIndexOptions::new(1_024),
12180 3,
12181 &bounded_control,
12182 );
12183 if !matches!(
12184 bounded_result,
12185 Err(CliError::IndexWork(
12186 IndexWorkFailure::ResourceLimitExceeded {
12187 stage: IndexWorkStage::TextIndex,
12188 resource: IndexWorkResource::TextBytes,
12189 limit: 3,
12190 observed: 4,
12191 }
12192 ))
12193 ) {
12194 return Err(io::Error::other("text staging accepted bytes beyond its limit").into());
12195 }
12196
12197 let bounded_symbol = parse_symbol_job(
12198 &SymbolParseJob {
12199 path: "bounded.txt".to_string(),
12200 native_path: bounded_path,
12201 expected_content_hash: blake3::hash(b"four").to_hex().to_string(),
12202 language: Some("text".to_string()),
12203 fallback_summary: None,
12204 purpose_needs_suggestion: false,
12205 },
12206 &SymbolBuildOptions::new(3, Some(1), None),
12207 Instant::now(),
12208 );
12209 if !matches!(
12210 bounded_symbol,
12211 SymbolParseOutcome::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
12212 stage: IndexWorkStage::SymbolParsing,
12213 resource: IndexWorkResource::SourceBytes,
12214 limit: 3,
12215 observed: 4,
12216 })
12217 ) {
12218 return Err(io::Error::other("symbol read accepted bytes beyond its limit").into());
12219 }
12220 Ok(())
12221 }
12222
12223 #[test]
12224 fn parser_workers_reuse_structural_summaries_without_touching_approved_purpose()
12225 -> Result<(), Box<dyn Error>> {
12226 let temp = tempfile::tempdir()?;
12227 let path = temp.path().join("package.json");
12228 let content =
12229 r#"{"name":"demo","scripts":{"test":"vitest"},"dependencies":{"react":"1.0.0"}}"#;
12230 fs::write(&path, content)?;
12231 let node = Node {
12232 path: "package.json".to_string(),
12233 kind: NodeKind::File,
12234 parent_path: None,
12235 extension: Some(".json".to_string()),
12236 language: Some("json".to_string()),
12237 size_bytes: Some(content.len() as u64),
12238 mtime_ns: Some(1),
12239 content_hash: Some(blake3::hash(content.as_bytes()).to_hex().to_string()),
12240 };
12241 let mut store = AtlasStore::in_memory()?;
12242 store.replace_scan(std::slice::from_ref(&node))?;
12243 store.set_purpose(
12244 "package.json",
12245 "Own the JavaScript package manifest.",
12246 PurposeSource::Agent,
12247 )?;
12248 let text = refresh_text_index_for_nodes_with_rows(
12249 &mut store,
12250 temp.path(),
12251 std::slice::from_ref(&node),
12252 TextIndexOptions::new(1_024),
12253 )?;
12254 let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
12255 &SymbolParseJob {
12256 path: node.path.clone(),
12257 native_path: path,
12258 expected_content_hash: node
12259 .content_hash
12260 .clone()
12261 .ok_or_else(|| io::Error::other("fixture hash missing"))?,
12262 language: node.language.clone(),
12263 fallback_summary: None,
12264 purpose_needs_suggestion: false,
12265 },
12266 &SymbolBuildOptions::new(1_024, Some(1), None),
12267 Instant::now(),
12268 ) else {
12269 return Err(io::Error::other("package manifest did not parse").into());
12270 };
12271 require_eq(
12272 &parsed.summary_is_structural,
12273 &true,
12274 "parser-owned structural summary",
12275 )?;
12276 require_eq(
12277 &parsed.summary.as_str(),
12278 &"package manifest for demo with scripts test and 1 dependencies.",
12279 "structural content summary",
12280 )?;
12281 require_eq(
12282 &parsed.purpose_suggestion.is_none(),
12283 &true,
12284 "approved-purpose suggestion suppression",
12285 )?;
12286 let retained_bytes = symbol_parse_output_bytes(&parsed);
12287 let mut symbols = SymbolBuildStage {
12288 report: empty_symbol_build_report(),
12289 changes: vec![SymbolProjectionChange::Parsed(parsed)],
12290 retained_bytes,
12291 identity_admission: graph_projection::GraphIdentityAdmission::default(),
12292 };
12293 let protected_purpose_paths = HashSet::from(["package.json".to_string()]);
12294 let control = standalone_index_work_control();
12295 let structural = stage_structural_summaries_for_nodes_controlled(
12296 &store,
12297 std::slice::from_ref(&node),
12298 &text.rows,
12299 Some(&symbols),
12300 &protected_purpose_paths,
12301 1,
12302 &control,
12303 )?;
12304 require_eq(
12305 &structural.report.summarized,
12306 &1,
12307 "structural summary report",
12308 )?;
12309 require_eq(
12310 &structural.report.purpose_suggestions,
12311 &0,
12312 "structural purpose-suggestion report",
12313 )?;
12314 require_eq(
12315 &structural.changes.is_empty(),
12316 &true,
12317 "duplicate structural mutations",
12318 )?;
12319
12320 apply_symbol_build_stage(&mut store, &mut symbols, &control)?;
12321 apply_structural_summary_stage(&mut store, &structural, &control)?;
12322 let indexed = store
12323 .load_node_by_path("package.json")?
12324 .ok_or_else(|| io::Error::other("indexed package manifest missing"))?;
12325 require_eq(
12326 &indexed.summary.as_deref(),
12327 &Some("package manifest for demo with scripts test and 1 dependencies."),
12328 "persisted structural content summary",
12329 )?;
12330 require_eq(
12331 &indexed.purpose.purpose.as_deref(),
12332 &Some("Own the JavaScript package manifest."),
12333 "approved purpose text",
12334 )?;
12335 require_eq(
12336 &indexed.purpose.status,
12337 &PurposeStatus::Approved,
12338 "approved purpose status",
12339 )?;
12340 Ok(())
12341 }
12342
12343 #[test]
12344 fn symbol_build_clamps_file_bytes_and_bounds_all_published_output() -> Result<(), Box<dyn Error>>
12345 {
12346 let temp = tempfile::tempdir()?;
12347 fs::write(
12348 temp.path().join("lib.rs"),
12349 "pub fn first() { second(); }\nfn second() {}\n",
12350 )?;
12351 let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
12352 let mut store = AtlasStore::in_memory()?;
12353 store.replace_scan(&nodes)?;
12354 let options = SymbolBuildOptions::new(u64::MAX, Some(INDEX_WORKER_SAFE_CEILING), None);
12355 require_eq(
12356 &options.max_bytes,
12357 &MAX_SYMBOL_FILE_BYTES,
12358 "effective CLI/MCP symbol file limit",
12359 )?;
12360 let control = standalone_index_work_control();
12361 for resource in [
12362 IndexWorkResource::SymbolRows,
12363 IndexWorkResource::RelationRows,
12364 IndexWorkResource::OutputBytes,
12365 ] {
12366 if !matches!(
12367 checked_symbol_publication_usage(1, 1, 1, resource),
12368 Err(CliError::IndexWork(
12369 IndexWorkFailure::ResourceLimitExceeded { observed: 2, .. }
12370 ))
12371 ) {
12372 return Err(io::Error::other(format!(
12373 "symbol publication did not accumulate {resource}"
12374 ))
12375 .into());
12376 }
12377 }
12378 let cases = [
12379 (
12380 SymbolPublicationLimits {
12381 symbol_rows: 0,
12382 relation_rows: u64::MAX,
12383 output_bytes: u64::MAX,
12384 },
12385 IndexWorkResource::SymbolRows,
12386 ),
12387 (
12388 SymbolPublicationLimits {
12389 symbol_rows: u64::MAX,
12390 relation_rows: 0,
12391 output_bytes: u64::MAX,
12392 },
12393 IndexWorkResource::RelationRows,
12394 ),
12395 (
12396 SymbolPublicationLimits {
12397 symbol_rows: u64::MAX,
12398 relation_rows: u64::MAX,
12399 output_bytes: 0,
12400 },
12401 IndexWorkResource::OutputBytes,
12402 ),
12403 ];
12404 for (limits, expected_resource) in cases {
12405 let result = build_symbols_for_paths_with_limits(
12406 &mut store,
12407 temp.path(),
12408 &options,
12409 None,
12410 None,
12411 &control,
12412 limits,
12413 );
12414 if !matches!(
12415 result,
12416 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
12417 stage: IndexWorkStage::SymbolParsing,
12418 resource,
12419 ..
12420 })) if resource == expected_resource
12421 ) {
12422 return Err(io::Error::other(format!(
12423 "symbol publication did not enforce {expected_resource}"
12424 ))
12425 .into());
12426 }
12427 require_eq(
12428 &store.symbol_count_for_path("lib.rs")?,
12429 &0,
12430 "no over-limit symbol output persisted",
12431 )?;
12432 }
12433
12434 let mut oversized_node = nodes
12435 .iter()
12436 .find(|node| node.path == "lib.rs")
12437 .cloned()
12438 .ok_or_else(|| io::Error::other("oversized symbol fixture node missing"))?;
12439 oversized_node.size_bytes = Some(4);
12440 let clear_bytes = oversized_node.path.len() as u64
12441 + oversized_node.language.as_ref().map_or(0, String::len) as u64;
12442 let clear_options = SymbolBuildOptions::new(3, Some(1), None);
12443 let clear_limits = SymbolPublicationLimits {
12444 symbol_rows: u64::MAX,
12445 relation_rows: u64::MAX,
12446 output_bytes: clear_bytes.saturating_sub(1),
12447 };
12448 let clear_result = stage_symbols_for_nodes_with_limits(
12449 &store,
12450 temp.path(),
12451 #[cfg(feature = "optional-parser-supervisor")]
12452 &OptionalParserPackProjectSelection::Inactive,
12453 std::slice::from_ref(&oversized_node),
12454 &clear_options,
12455 None,
12456 None,
12457 &[],
12458 &HashSet::new(),
12459 &control,
12460 clear_limits,
12461 );
12462 if !matches!(
12463 clear_result,
12464 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
12465 stage: IndexWorkStage::SymbolParsing,
12466 resource: IndexWorkResource::OutputBytes,
12467 limit,
12468 observed,
12469 })) if limit == clear_bytes.saturating_sub(1) && observed == clear_bytes
12470 ) {
12471 return Err(
12472 io::Error::other("symbol clear output bypassed its retained-byte limit").into(),
12473 );
12474 }
12475 let clear_stage = stage_symbols_for_nodes_with_limits(
12476 &store,
12477 temp.path(),
12478 #[cfg(feature = "optional-parser-supervisor")]
12479 &OptionalParserPackProjectSelection::Inactive,
12480 std::slice::from_ref(&oversized_node),
12481 &clear_options,
12482 None,
12483 None,
12484 &[],
12485 &HashSet::new(),
12486 &control,
12487 SymbolPublicationLimits {
12488 output_bytes: clear_bytes,
12489 ..SymbolPublicationLimits::STANDARD
12490 },
12491 )?;
12492 require_eq(
12493 &clear_stage.retained_bytes,
12494 &clear_bytes,
12495 "retained symbol clear bytes",
12496 )?;
12497 if !matches!(
12498 clear_stage.changes.as_slice(),
12499 [SymbolProjectionChange::Clear { path, language }]
12500 if path == "lib.rs" && language.as_deref() == Some("rust")
12501 ) {
12502 return Err(
12503 io::Error::other("oversized symbol output did not retain one clear").into(),
12504 );
12505 }
12506
12507 let report = build_symbols_for_paths_with_limits(
12508 &mut store,
12509 temp.path(),
12510 &options,
12511 None,
12512 None,
12513 &control,
12514 SymbolPublicationLimits::STANDARD,
12515 )?;
12516 require_eq(&report.parsed, &1, "compatible bounded symbol build")?;
12517 require_eq(&report.max_workers, &1, "single-job symbol worker count")?;
12518 if report.symbols == 0 || report.relations == 0 {
12519 return Err(io::Error::other("bounded symbol build omitted parser output").into());
12520 }
12521 Ok(())
12522 }
12523
12524 #[test]
12525 fn markdown_parse_budget_counts_enclosing_heading_identity() -> Result<(), Box<dyn Error>> {
12526 let temp = tempfile::tempdir()?;
12527 let path = temp.path().join("guide.md");
12528 let content = "# Guide\n\n[target](target.md)\n";
12529 fs::write(&path, content)?;
12530 let SymbolParseOutcome::Parsed(mut parsed) = parse_symbol_job(
12531 &SymbolParseJob {
12532 path: "guide.md".to_string(),
12533 native_path: path,
12534 expected_content_hash: blake3::hash(content.as_bytes()).to_hex().to_string(),
12535 language: Some("markdown".to_string()),
12536 fallback_summary: None,
12537 purpose_needs_suggestion: false,
12538 },
12539 &SymbolBuildOptions::new(1_024, Some(1), None),
12540 Instant::now(),
12541 ) else {
12542 return Err(io::Error::other("Markdown fixture did not parse").into());
12543 };
12544 let enclosing_heading_bytes = parsed
12545 .markdown_facts
12546 .as_ref()
12547 .ok_or_else(|| io::Error::other("Markdown facts missing"))?
12548 .link_candidates
12549 .iter()
12550 .map(|candidate| candidate.enclosing_heading.as_ref().map_or(0, String::len) as u64)
12551 .sum::<u64>();
12552 let retained_with_heading_owners = symbol_parse_output_bytes(&parsed);
12553 for candidate in &mut parsed
12554 .markdown_facts
12555 .as_mut()
12556 .ok_or_else(|| io::Error::other("Markdown facts missing"))?
12557 .link_candidates
12558 {
12559 candidate.enclosing_heading = None;
12560 }
12561 require_eq(
12562 &retained_with_heading_owners.saturating_sub(symbol_parse_output_bytes(&parsed)),
12563 &enclosing_heading_bytes,
12564 "parser output enclosing-heading bytes",
12565 )?;
12566 Ok(())
12567 }
12568
12569 #[test]
12570 fn purpose_import_skips_non_utf8_source_headers_but_keeps_authored_inputs_strict()
12571 -> Result<(), Box<dyn Error>> {
12572 let temp = tempfile::tempdir()?;
12573 let config_path = init_config_path(temp.path(), None);
12574 init_project_with_config(temp.path(), Some(&config_path))?;
12575 let config = load_atlas_config(Some(&config_path))?;
12576 fs::write(
12577 temp.path().join("binary.txt"),
12578 b"// Purpose: Must not be imported from binary source.\n\xff",
12579 )?;
12580 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12581 let nodes = scan_repo(&plan.root, &plan.scan_options)?;
12582 if !nodes.iter().any(|node| node.path == "binary.txt") {
12583 return Err(io::Error::other("binary source fixture was not scanned").into());
12584 }
12585 let snapshot =
12586 plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control())?;
12587 if snapshot
12588 .records
12589 .iter()
12590 .any(|record| record.path == "binary.txt")
12591 {
12592 return Err(io::Error::other("non-UTF-8 source header imported a purpose").into());
12593 }
12594
12595 fs::write(&config.map_path, [0xff])?;
12596 let strict =
12597 plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control());
12598 if !matches!(
12599 strict,
12600 Err(CliError::InvalidInput(message))
12601 if message.contains("purpose input is not valid UTF-8")
12602 ) {
12603 return Err(
12604 io::Error::other("non-UTF-8 authored purpose input was not rejected").into(),
12605 );
12606 }
12607 Ok(())
12608 }
12609
12610 #[test]
12611 fn purpose_import_inputs_observe_cancellation_limits_and_rollback() -> Result<(), Box<dyn Error>>
12612 {
12613 struct CancelAfterFirstRead {
12614 bytes: io::Cursor<Vec<u8>>,
12615 cancellation: IndexCancellation,
12616 reads: usize,
12617 }
12618
12619 impl Read for CancelAfterFirstRead {
12620 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
12621 let read = self.bytes.read(buffer)?;
12622 self.reads += 1;
12623 if self.reads == 1 {
12624 self.cancellation.cancel();
12625 }
12626 Ok(read)
12627 }
12628 }
12629
12630 let temp = tempfile::tempdir()?;
12631 let config_path = init_config_path(temp.path(), None);
12632 init_project_with_config(temp.path(), Some(&config_path))?;
12633 let fixture_config = load_atlas_config(Some(&config_path))?;
12634 fs::write(temp.path().join("lib.rs"), "fn imported() {}\n")?;
12635 fs::write(
12636 &fixture_config.map_path,
12637 "folders[1]:\n .,Controlled imported repository purpose\n",
12638 )?;
12639 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12640 let nodes = scan_repo(&plan.root, &plan.scan_options)?;
12641 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12642 let mut store = AtlasStore::in_memory()?;
12643 store.set_project_root(&plan.root)?;
12644 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
12645 let publication_before = store
12646 .index_publication()?
12647 .ok_or_else(|| io::Error::other("controlled import publication missing"))?;
12648 let root_before = store
12649 .load_node_by_path(".")?
12650 .ok_or_else(|| io::Error::other("controlled import root missing"))?;
12651 let text_before = store
12652 .load_file_text("lib.rs")?
12653 .ok_or_else(|| io::Error::other("controlled import text missing"))?;
12654 let symbol_count_before = store.symbol_count()?;
12655 let relation_count_before = store.symbol_relation_count()?;
12656
12657 let config_bytes = fs::metadata(&config_path)?.len();
12658 let map_bytes = fs::metadata(&fixture_config.map_path)?.len();
12659 let nonsource_bytes = fs::metadata(&fixture_config.nonsource_files_path)?.len();
12660 let source_bytes = fs::metadata(temp.path().join("lib.rs"))?.len();
12661 let staged_input_bytes = config_bytes
12662 .saturating_add(map_bytes)
12663 .saturating_add(nonsource_bytes)
12664 .saturating_add(source_bytes);
12665 let complete_operation_bytes = config_bytes
12666 .saturating_add(staged_input_bytes)
12667 .saturating_add(config_bytes)
12668 .saturating_add(staged_input_bytes);
12669 let largest_complete_input = config_bytes.max(map_bytes).max(nonsource_bytes);
12670 let aggregate_limits = PurposeImportLimits {
12671 total_bytes: complete_operation_bytes,
12672 complete_file_bytes: largest_complete_input,
12673 header_bytes: source_bytes,
12674 records: 100,
12675 };
12676 let aggregate_control = standalone_index_work_control();
12677 let aggregate_plan = ScanRuntimePlan::for_path_controlled_with_limits(
12678 None,
12679 temp.path(),
12680 None,
12681 &aggregate_control,
12682 aggregate_limits,
12683 )?;
12684 let aggregate_nodes = scan_repo(&aggregate_plan.root, &aggregate_plan.scan_options)?;
12685 let aggregate_snapshot = aggregate_plan.purpose_import_snapshot_controlled_with_limits(
12686 &aggregate_nodes,
12687 &aggregate_control,
12688 aggregate_limits,
12689 )?;
12690 revalidate_index_publication_inputs_controlled_with_limits(
12691 &store,
12692 &aggregate_plan,
12693 Some(&aggregate_snapshot.fingerprint),
12694 &aggregate_control,
12695 aggregate_limits,
12696 )?;
12697
12698 let cumulative_limit = complete_operation_bytes.saturating_sub(1);
12699 let cumulative_limits = PurposeImportLimits {
12700 total_bytes: cumulative_limit,
12701 ..aggregate_limits
12702 };
12703 let cumulative_control = standalone_index_work_control();
12704 let cumulative_plan = ScanRuntimePlan::for_path_controlled_with_limits(
12705 None,
12706 temp.path(),
12707 None,
12708 &cumulative_control,
12709 cumulative_limits,
12710 )?;
12711 let cumulative_nodes = scan_repo(&cumulative_plan.root, &cumulative_plan.scan_options)?;
12712 let cumulative_snapshot = cumulative_plan.purpose_import_snapshot_controlled_with_limits(
12713 &cumulative_nodes,
12714 &cumulative_control,
12715 cumulative_limits,
12716 )?;
12717 let cumulative_result = revalidate_index_publication_inputs_controlled_with_limits(
12718 &store,
12719 &cumulative_plan,
12720 Some(&cumulative_snapshot.fingerprint),
12721 &cumulative_control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT),
12722 cumulative_limits,
12723 );
12724 if !matches!(
12725 cumulative_result,
12726 Err(CliError::IndexWork(
12727 IndexWorkFailure::ResourceLimitExceeded {
12728 stage: IndexWorkStage::Publication,
12729 resource: IndexWorkResource::PurposeBytes,
12730 limit,
12731 observed,
12732 }
12733 )) if limit == cumulative_limit && observed > limit
12734 ) {
12735 return Err(io::Error::other(
12736 "plan, staging, and revalidation readers did not share one purpose-byte budget",
12737 )
12738 .into());
12739 }
12740
12741 let control = standalone_index_work_control();
12742 let staged_snapshot = plan.purpose_import_snapshot_controlled(&nodes, &control)?;
12743 let small_limits = PurposeImportLimits {
12744 total_bytes: 64,
12745 complete_file_bytes: 64,
12746 header_bytes: 64,
12747 records: 100,
12748 };
12749 let initial_limit_control = standalone_index_work_control();
12750 let initial_limited = plan.purpose_import_snapshot_controlled_with_limits(
12751 &nodes,
12752 &initial_limit_control,
12753 small_limits,
12754 );
12755 if !matches!(
12756 initial_limited,
12757 Err(CliError::IndexWork(
12758 IndexWorkFailure::ResourceLimitExceeded {
12759 stage: IndexWorkStage::Publication,
12760 resource: IndexWorkResource::PurposeBytes,
12761 limit: 64,
12762 observed: 65,
12763 }
12764 ))
12765 ) {
12766 return Err(io::Error::other(
12767 "initial purpose snapshot exceeded limits without a typed failure",
12768 )
12769 .into());
12770 }
12771 let initial_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
12772 initial_cancel.cancel();
12773 if !matches!(
12774 plan.purpose_import_snapshot_controlled(&nodes, &initial_cancel),
12775 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12776 stage: IndexWorkStage::Publication,
12777 }))
12778 ) {
12779 return Err(io::Error::other(
12780 "initial purpose snapshot ignored operation cancellation",
12781 )
12782 .into());
12783 }
12784
12785 let contract_fingerprint = plan.publication_contract_fingerprint();
12786 let mut publication = store.begin_index_publication(&contract_fingerprint)?;
12787 publication.set_project_root(&plan.root)?;
12788 publication.replace_scan(&nodes)?;
12789 publication.set_purpose(".", "Uncommitted purpose input", PurposeSource::Imported)?;
12790 let publication_limit_control = standalone_index_work_control();
12791 let limited = revalidate_index_publication_inputs_controlled_with_limits(
12792 &publication,
12793 &plan,
12794 Some(&staged_snapshot.fingerprint),
12795 &publication_limit_control,
12796 small_limits,
12797 );
12798 if !matches!(
12799 limited,
12800 Err(CliError::IndexWork(
12801 IndexWorkFailure::ResourceLimitExceeded {
12802 stage: IndexWorkStage::Publication,
12803 resource: IndexWorkResource::PurposeBytes,
12804 limit: 64,
12805 observed: 65,
12806 }
12807 ))
12808 ) {
12809 return Err(io::Error::other(
12810 "publication purpose inputs exceeded limits without a typed failure",
12811 )
12812 .into());
12813 }
12814 drop(publication);
12815 require_eq(
12816 &store.index_publication()?,
12817 &Some(publication_before.clone()),
12818 "publication after bounded purpose-input rollback",
12819 )?;
12820 require_eq(
12821 &store.load_node_by_path(".")?,
12822 &Some(root_before.clone()),
12823 "authored purpose after bounded input rollback",
12824 )?;
12825 require_eq(
12826 &store.load_file_text("lib.rs")?,
12827 &Some(text_before.clone()),
12828 "indexed text after bounded input rollback",
12829 )?;
12830 require_eq(
12831 &store.symbol_count()?,
12832 &symbol_count_before,
12833 "symbols after bounded input rollback",
12834 )?;
12835 require_eq(
12836 &store.symbol_relation_count()?,
12837 &relation_count_before,
12838 "relations after bounded input rollback",
12839 )?;
12840
12841 let mut canceled_publication = store.begin_index_publication(&contract_fingerprint)?;
12842 canceled_publication.set_project_root(&plan.root)?;
12843 canceled_publication.replace_scan(&nodes)?;
12844 canceled_publication.set_purpose(
12845 ".",
12846 "Canceled uncommitted purpose input",
12847 PurposeSource::Imported,
12848 )?;
12849 let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
12850 late_cancel.cancel();
12851 let canceled = revalidate_index_publication_inputs_controlled(
12852 &canceled_publication,
12853 &plan,
12854 Some(&staged_snapshot.fingerprint),
12855 &late_cancel,
12856 );
12857 if !matches!(
12858 canceled,
12859 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12860 stage: IndexWorkStage::Publication,
12861 }))
12862 ) {
12863 return Err(io::Error::other(
12864 "late purpose-input revalidation ignored operation cancellation",
12865 )
12866 .into());
12867 }
12868 drop(canceled_publication);
12869 require_eq(
12870 &store.index_publication()?,
12871 &Some(publication_before),
12872 "publication after canceled purpose-input rollback",
12873 )?;
12874 require_eq(
12875 &store.load_node_by_path(".")?,
12876 &Some(root_before),
12877 "authored purpose after canceled input rollback",
12878 )?;
12879 require_eq(
12880 &store.load_file_text("lib.rs")?,
12881 &Some(text_before),
12882 "indexed text after canceled input rollback",
12883 )?;
12884 require_eq(
12885 &store.symbol_count()?,
12886 &symbol_count_before,
12887 "symbols after canceled input rollback",
12888 )?;
12889 require_eq(
12890 &store.symbol_relation_count()?,
12891 &relation_count_before,
12892 "relations after canceled input rollback",
12893 )?;
12894
12895 let record_limited = plan.purpose_import_snapshot_controlled_with_limits(
12896 &nodes,
12897 &control,
12898 PurposeImportLimits {
12899 records: 0,
12900 ..PurposeImportLimits::default()
12901 },
12902 );
12903 if !matches!(
12904 record_limited,
12905 Err(CliError::IndexWork(
12906 IndexWorkFailure::ResourceLimitExceeded {
12907 stage: IndexWorkStage::Publication,
12908 resource: IndexWorkResource::PurposeRecords,
12909 limit: 0,
12910 observed,
12911 }
12912 )) if observed > 0
12913 ) {
12914 return Err(io::Error::other("purpose record limit was not enforced").into());
12915 }
12916
12917 let cancellation = IndexCancellation::new();
12918 let cancel_control = IndexWorkControl::new(cancellation.clone(), None);
12919 let mut reader =
12920 PurposeInputReader::new(&plan, &cancel_control, PurposeImportLimits::default());
12921 let mut input = CancelAfterFirstRead {
12922 bytes: io::Cursor::new(vec![b'x'; CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2]),
12923 cancellation,
12924 reads: 0,
12925 };
12926 let canceled = reader.read_bytes(
12927 Path::new("controlled-purpose-input"),
12928 &mut input,
12929 u64::try_from(CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2).unwrap_or(u64::MAX),
12930 true,
12931 );
12932 if !matches!(
12933 canceled,
12934 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
12935 stage: IndexWorkStage::Publication,
12936 }))
12937 ) {
12938 return Err(io::Error::other(
12939 "purpose input did not observe cancellation between chunks",
12940 )
12941 .into());
12942 }
12943 Ok(())
12944 }
12945
12946 #[test]
12947 fn publication_revalidation_rejects_source_policy_and_import_drift()
12948 -> Result<(), Box<dyn Error>> {
12949 let temp = tempfile::tempdir()?;
12950 let config_dir = temp.path().join(".projectatlas");
12951 fs::create_dir_all(&config_dir)?;
12952 let source_path = temp.path().join("lib.rs");
12953 let staged_source = "fn first() {}\n";
12954 fs::write(&source_path, staged_source)?;
12955 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
12956 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
12957 let db_path = config_dir.join("projectatlas.db");
12958 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
12959 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
12960 let control = standalone_index_work_control();
12961 let generation_before_contention = store
12962 .index_publication()?
12963 .ok_or_else(|| io::Error::other("initial publication missing"))?
12964 .generation;
12965 let contract_fingerprint = plan.publication_contract_fingerprint();
12966 store.probe_index_publication_writer()?;
12967 let contended_batch =
12968 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
12969 revalidate_staged_publication_inputs_controlled(
12970 &plan,
12971 contended_batch.nodes.expected_nodes(),
12972 None,
12973 &control,
12974 )?;
12975 let writer_blocker = rusqlite::Connection::open(&db_path)?;
12976 writer_blocker.execute_batch("BEGIN IMMEDIATE")?;
12977 let source_after_contention = "fn later() {}\n";
12978 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
12979 let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
12980 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
12981 let publisher = std::thread::spawn(move || {
12982 if ready_tx.send(()).is_err() || publish_rx.recv().is_err() {
12983 return;
12984 }
12985 let publication_control = standalone_index_work_control();
12986 let result = publish_index_batch(&mut store, contended_batch, &publication_control);
12987 drop(result_tx.send((store, result)));
12988 });
12989 ready_rx
12990 .recv_timeout(Duration::from_secs(1))
12991 .map_err(|source| {
12992 io::Error::other(format!("publisher did not become ready: {source}"))
12993 })?;
12994 fs::write(&source_path, source_after_contention)?;
12995 publish_tx
12996 .send(())
12997 .map_err(|source| io::Error::other(format!("publisher stopped early: {source}")))?;
12998 let timely_result = result_rx.recv_timeout(Duration::from_millis(500));
12999 writer_blocker.execute_batch("ROLLBACK")?;
13000 let (returned_store, contention_result) = match timely_result {
13001 Ok(result) => result,
13002 Err(source) => {
13003 publisher
13004 .join()
13005 .map_err(|_panic| io::Error::other("publication thread panicked"))?;
13006 return Err(io::Error::other(format!(
13007 "publication waited for a contending writer instead of failing fast: {source}"
13008 ))
13009 .into());
13010 }
13011 };
13012 publisher
13013 .join()
13014 .map_err(|_panic| io::Error::other("publication thread panicked"))?;
13015 store = returned_store;
13016 let Err(CliError::Db(contention)) = contention_result else {
13017 return Err(io::Error::other(
13018 "publication waited through contention and accepted stale staged source",
13019 )
13020 .into());
13021 };
13022 if !contention.is_write_unavailable() {
13023 return Err(io::Error::other(
13024 "publication contention did not return typed write-unavailable state",
13025 )
13026 .into());
13027 }
13028 require_eq(
13029 &store
13030 .index_publication()?
13031 .ok_or_else(|| io::Error::other("publication missing after contention"))?
13032 .generation,
13033 &generation_before_contention,
13034 "publication generation after contention",
13035 )?;
13036 require_eq(
13037 &store
13038 .load_file_text("lib.rs")?
13039 .ok_or_else(|| io::Error::other("indexed text missing after contention"))?
13040 .content,
13041 &staged_source.to_string(),
13042 "indexed text after contention",
13043 )?;
13044 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
13045 require_eq(
13046 &store
13047 .load_file_text("lib.rs")?
13048 .ok_or_else(|| io::Error::other("indexed text missing after retry"))?
13049 .content,
13050 &source_after_contention.to_string(),
13051 "restaged text after contention retry",
13052 )?;
13053 let initial_generation = store
13054 .index_publication()?
13055 .ok_or_else(|| io::Error::other("retried publication missing"))?
13056 .generation;
13057 require_eq(
13058 &initial_generation,
13059 &generation_before_contention
13060 .checked_next()
13061 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
13062 "single generation advance after contention retry",
13063 )?;
13064 let mut competing_store = open_atlas_store_for_project(&db_path, &plan.root)?;
13065 let competing_publication = competing_store
13066 .begin_index_projection_refresh_from(&contract_fingerprint, initial_generation)?;
13067 competing_publication.set_node_summary("lib.rs", "winning projection")?;
13068 let generation_conflict_batch =
13069 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13070 competing_publication.complete()?;
13071 let winning_generation = initial_generation
13072 .checked_next()
13073 .ok_or_else(|| io::Error::other("test generation overflowed"))?;
13074 let conflict = publish_index_batch(&mut store, generation_conflict_batch, &control);
13075 if !matches!(
13076 conflict,
13077 Err(CliError::Db(
13078 projectatlas_db::DbError::PublicationBaseGenerationChanged {
13079 expected,
13080 found,
13081 }
13082 )) if expected == initial_generation && found == winning_generation
13083 ) {
13084 return Err(io::Error::other(
13085 "runtime publication did not reject a generation changed after preparation",
13086 )
13087 .into());
13088 }
13089 let winning_publication = store
13090 .index_publication()?
13091 .ok_or_else(|| io::Error::other("winning publication missing"))?;
13092 require_eq(
13093 &winning_publication.generation,
13094 &winning_generation,
13095 "winning publication generation",
13096 )?;
13097 require_eq(
13098 &winning_publication.state,
13099 &projectatlas_db::IndexPublicationState::Complete,
13100 "winning publication state",
13101 )?;
13102 require_eq(
13103 &store
13104 .load_node_by_path("lib.rs")?
13105 .and_then(|node| node.summary),
13106 &Some("winning projection".to_string()),
13107 "winning publication summary",
13108 )?;
13109 let staged_source_batch =
13110 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13111
13112 fs::write(&source_path, "fn other() {}\n")?;
13113 let source_result = revalidate_staged_publication_inputs_controlled(
13114 &plan,
13115 staged_source_batch.nodes.expected_nodes(),
13116 None,
13117 &control,
13118 );
13119 let Err(CliError::RefreshRequired(details)) = source_result else {
13120 return Err(io::Error::other("publication accepted changed source state").into());
13121 };
13122 require_eq(
13123 &details.reason,
13124 &IndexRefreshReason::SourceChanged,
13125 "publication source-change reason",
13126 )?;
13127 require_eq(
13128 &details.sample_paths,
13129 &vec!["lib.rs".to_string()],
13130 "publication source-change paths",
13131 )?;
13132
13133 fs::write(&source_path, staged_source)?;
13134 let staged_policy_batch =
13135 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13136 let config_path = config_dir.join("config.toml");
13137 fs::write(
13138 &config_path,
13139 r#"[project]
13140root = "."
13141map_path = ".projectatlas/projectatlas.toon"
13142nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
13143
13144[scan]
13145source_extensions = [".rs"]
13146exclude_dir_names = [".git", ".projectatlas", "target"]
13147exclude_dir_suffixes = []
13148exclude_path_prefixes = []
13149non_source_path_prefixes = []
13150text_index_max_bytes = 7
13151
13152[purpose]
13153default_style = "line-comment"
13154line_comment_prefixes = ["//"]
13155
13156[purpose.styles_by_extension]
13157".rs" = "line-comment"
13158"#,
13159 )?;
13160 let policy_result = revalidate_staged_publication_inputs_controlled(
13161 &plan,
13162 staged_policy_batch.nodes.expected_nodes(),
13163 None,
13164 &control,
13165 );
13166 let Err(CliError::VerificationIncomplete(details)) = policy_result else {
13167 return Err(io::Error::other("publication accepted changed effective policy").into());
13168 };
13169 require_eq(
13170 &details.reason,
13171 &IndexVerificationReason::PublicationContractMismatch,
13172 "publication policy-change reason",
13173 )?;
13174
13175 let configured_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
13176 let request_limited_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
13177 require_eq(
13178 &configured_plan.text_options.max_bytes,
13179 &7,
13180 "configured text-index limit",
13181 )?;
13182 require_eq(
13183 &request_limited_plan.text_options.max_bytes,
13184 &1,
13185 "request-scoped text-index limit",
13186 )?;
13187 require_eq(
13188 &configured_plan.publication_contract_fingerprint(),
13189 &request_limited_plan.publication_contract_fingerprint(),
13190 "request limit excluded from publication contract",
13191 )?;
13192 let reloaded_request_plan = request_limited_plan.reload()?;
13193 require_eq(
13194 &reloaded_request_plan.text_options.max_bytes,
13195 &1,
13196 "request limit retained across operation reload",
13197 )?;
13198 require_eq(
13199 &request_limited_plan.publication_contract_fingerprint(),
13200 &reloaded_request_plan.publication_contract_fingerprint(),
13201 "reloaded operation publication contract",
13202 )?;
13203
13204 let changed_config = fs::read_to_string(&config_path)?
13205 .replace("text_index_max_bytes = 7", "text_index_max_bytes = 8");
13206 fs::write(&config_path, changed_config)?;
13207 let configured_cap_changed_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
13208 if configured_plan.publication_contract_fingerprint()
13209 == configured_cap_changed_plan.publication_contract_fingerprint()
13210 {
13211 return Err(io::Error::other(
13212 "configured text-index limit did not change the publication contract",
13213 )
13214 .into());
13215 }
13216
13217 let import_repo = temp.path().join("import-repo");
13218 let import_atlas_dir = import_repo.join(".projectatlas");
13219 fs::create_dir_all(&import_atlas_dir)?;
13220 fs::write(import_repo.join("lib.rs"), "fn imported() {}\n")?;
13221 let import_map_path = import_atlas_dir.join("projectatlas.toon");
13222 fs::write(
13223 &import_map_path,
13224 "folders[1]:\n .,Original imported repository purpose\n",
13225 )?;
13226 let external_config_path = temp.path().join("external-config.toml");
13227 fs::write(
13228 &external_config_path,
13229 r#"[project]
13230root = "import-repo"
13231map_path = ".projectatlas/projectatlas.toon"
13232nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
13233"#,
13234 )?;
13235 let import_plan =
13236 ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, Some(1))?;
13237 let mut import_store = open_atlas_store_for_project(
13238 &import_atlas_dir.join("projectatlas.db"),
13239 &import_plan.root,
13240 )?;
13241 run_scan_pipeline(&mut import_store, &import_plan, &symbol_options)?;
13242 verify_index_freshness(&import_store, &import_repo, Some(&external_config_path))?;
13243 let normal_import_plan =
13244 ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, None)?;
13245 run_symbol_build_pipeline(
13246 &mut import_store,
13247 &normal_import_plan,
13248 &symbol_options,
13249 None,
13250 )?;
13251 verify_index_publication(&import_store, &normal_import_plan)?;
13252 let publication_before = import_store
13253 .index_publication()?
13254 .ok_or_else(|| io::Error::other("initial imported publication missing"))?;
13255 let root_before = import_store
13256 .load_node_by_path(".")?
13257 .ok_or_else(|| io::Error::other("imported root node missing"))?;
13258 if root_before.purpose.purpose.as_deref() != Some("Original imported repository purpose") {
13259 return Err(io::Error::other("legacy purpose fixture was not imported").into());
13260 }
13261
13262 let import_control = standalone_index_work_control();
13263 let staged_import_batch = stage_full_index_publication(
13264 &import_store,
13265 &import_plan,
13266 &symbol_options,
13267 true,
13268 true,
13269 &import_control,
13270 )?;
13271 let staged_purpose_import = staged_import_batch
13272 .purpose_import
13273 .as_ref()
13274 .ok_or_else(|| io::Error::other("staged purpose import missing"))?;
13275 if !staged_purpose_import
13276 .records
13277 .iter()
13278 .any(|record| record.summary == "Original imported repository purpose")
13279 {
13280 return Err(io::Error::other("legacy purpose fixture was not imported").into());
13281 }
13282 fs::write(
13283 &import_map_path,
13284 "folders[1]:\n .,Changed imported repository purpose\n",
13285 )?;
13286 let import_result = revalidate_staged_publication_inputs_with_purpose_snapshot(
13287 &import_plan,
13288 staged_import_batch.nodes.expected_nodes(),
13289 Some(staged_purpose_import),
13290 &import_control,
13291 );
13292 let Err(CliError::VerificationIncomplete(details)) = import_result else {
13293 return Err(io::Error::other(
13294 "publication accepted changed legacy purpose import inputs",
13295 )
13296 .into());
13297 };
13298 require_eq(
13299 &details.reason,
13300 &IndexVerificationReason::PublicationContractMismatch,
13301 "publication import-change reason",
13302 )?;
13303 require_eq(
13304 &import_store.index_publication()?,
13305 &Some(publication_before),
13306 "publication after purpose-import rollback",
13307 )?;
13308 require_eq(
13309 &import_store.load_node_by_path(".")?,
13310 &Some(root_before),
13311 "authored purpose after purpose-import rollback",
13312 )?;
13313 Ok(())
13314 }
13315
13316 #[test]
13317 fn projection_contract_revisions_force_full_projection_refresh() -> Result<(), Box<dyn Error>> {
13318 const PRE_MODULE_CALLBACK_DIGEST: &str =
13319 "487625adf2f9ec76f98034d4ef5667e707960b6b8afd280b213021cb64a0f10f";
13320 const PRE_BINDING_OWNERSHIP_DIGEST: &str =
13321 "70149049f404b00bd7d956adabda377be3509080bea68a8978eac3501fee0929";
13322 const PRE_OCCURRENCE_PROJECTION_DIGEST: &str =
13323 "8e0d5f3a34d88507e9d3c64857949a299bdbf516a1eca06adf0cbcd7ca7962a4";
13324 const STABLE_050_SEMANTIC_DIGEST: &str =
13325 "deba1bcef2b03869c211b5ba91036bb87e7cdd9c9ebb565ed447d6aea78ca535";
13326 const PRE_GENERATOR_DECLARATION_DIGEST: &str =
13327 "4542cd9a22a1df432d3a3d212f2ee5b6c52067411c3f54ca77221566a8f2ab7d";
13328 const PRE_SOURCE_CONTEXT_DIGEST: &str =
13329 "784435da80445735c78b112bebab6352f74c660d550399a9617dae87d2df0424";
13330 const PRE_CARGO_COHORT_DIGEST: &str =
13331 "77ce9ef63aa9da217f35e41d59470395e7be08d45f167c14b2477eb29309d2ca";
13332 const PRE_GO_CLAUSE_DIGEST: &str =
13333 "e0bc8aa6d30d159c0e1eae3b80773fb0f4031398608b01e1bba31b6b8d6cbd04";
13334 const PRE_COMPREHENSION_ORDER_DIGEST: &str =
13335 "9d16d41a80b1115f4d0706d7046f1be092fd2bd4a2abc41ba5d78749970d0eb6";
13336 const PRE_QUALIFIED_OWNER_DIGEST: &str =
13337 "d662ae01fce4a173b5b0e520a7cf0465d7b7184528b914afa24b9145c3efe8ed";
13338 const PRE_IDENTITY_NEIGHBOR_DIGEST: &str =
13339 "79d37888e9dff0e1ac37dfce6970a34b3ee8058b32dc0f973338153a20ac523b";
13340 const PRE_ACCESSOR_PARAMETER_DIGEST: &str =
13341 "4c7a58829b2910e38015f4576ad26e458a8192363e3024d732467db2355abac4";
13342 const PRE_MEMBER_OWNERSHIP_DIGEST: &str =
13343 "5627189a6bc0d67d2483073582b55ba3d9ea04c55ef3177462ce584361c7c355";
13344 const PRE_INSTANCE_FIELD_DIGEST: &str =
13345 "1b695fb9372249524c55c19c6969478f8a10b0414471de4eb8e467249c4baaf1";
13346 const PRE_REOPENED_CONTAINER_DIGEST: &str =
13347 "6a6912198f2cc054d76072375eb328f285f5c232710a6dc2d698169b3e7ef65a";
13348 const PRE_ARROW_CONTEXT_DIGEST: &str =
13349 "66cab1855f1689308cdc5bb858a63e8d3ed42e825b073d62cada7347383731a7";
13350 const PRE_FUNCTION_ENVIRONMENT_DIGEST: &str =
13351 "a1d3ff14480c0efd0dc36ab9abe78ea6ac9423b62ef2ce52b36996436e06b421";
13352 const PRE_RECOVERY_ADMISSION_DIGEST: &str =
13353 "7ebce4c0ac31d1a9bb12313ae7777133d6f7c3c712ec3a147a244b2cc0edf7de";
13354 const PRE_VAR_RESERVATION_DIGEST: &str =
13355 "a5fd0cc0717afd6c733563637f6f24ba86f08ec447422edc319f26d480f24267";
13356 const PRE_DIRECTIVE_BINDING_DIGEST: &str =
13357 "4f50804d1e3b708c7081f654b39287fbca88adaeb4dfbbbb60fb5c708404e626";
13358 let temp = tempfile::tempdir()?;
13359 let atlas_dir = temp.path().join(".projectatlas");
13360 fs::create_dir_all(&atlas_dir)?;
13361 fs::create_dir_all(temp.path().join("src"))?;
13362 fs::create_dir_all(temp.path().join("docs"))?;
13363 fs::write(
13364 temp.path().join("src/config.rs"),
13365 "pub fn load_timeout_millis() -> u64 { 250 }\n",
13366 )?;
13367 fs::write(
13368 temp.path().join("src/handler.rs"),
13369 "use crate::config;\npub fn health_response() { let _ = config::load_timeout_millis(); }\n",
13370 )?;
13371 fs::write(
13372 temp.path().join("src/router.rs"),
13373 "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
13374 )?;
13375 fs::write(
13376 temp.path().join("docs/guide.md"),
13377 "# Configuration\n\nSee [the timeout source](../src/config.rs).\n",
13378 )?;
13379
13380 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
13381 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
13382 let db_path = atlas_dir.join("projectatlas.db");
13383 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
13384 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
13385 let current_fingerprint = plan.publication_contract_fingerprint();
13386 let prior_projection_fingerprint = index_derivation_fingerprint_for_contract(
13387 &plan.scan_options,
13388 text_index_options(plan.config.as_ref(), None),
13389 #[cfg(feature = "optional-parser-supervisor")]
13390 &plan.optional_parser_selection,
13391 "23",
13392 PRE_CARGO_COHORT_DIGEST,
13393 );
13394 let cargo_projection_fingerprint = index_derivation_fingerprint_for_contract(
13395 &plan.scan_options,
13396 text_index_options(plan.config.as_ref(), None),
13397 #[cfg(feature = "optional-parser-supervisor")]
13398 &plan.optional_parser_selection,
13399 "22",
13400 PRE_CARGO_COHORT_DIGEST,
13401 );
13402 let go_clause_projection_fingerprint = index_derivation_fingerprint_for_contract(
13403 &plan.scan_options,
13404 text_index_options(plan.config.as_ref(), None),
13405 #[cfg(feature = "optional-parser-supervisor")]
13406 &plan.optional_parser_selection,
13407 "21",
13408 PRE_GO_CLAUSE_DIGEST,
13409 );
13410 let comprehension_projection_fingerprint = index_derivation_fingerprint_for_contract(
13411 &plan.scan_options,
13412 text_index_options(plan.config.as_ref(), None),
13413 #[cfg(feature = "optional-parser-supervisor")]
13414 &plan.optional_parser_selection,
13415 "20",
13416 PRE_COMPREHENSION_ORDER_DIGEST,
13417 );
13418 let qualified_owner_projection_fingerprint = index_derivation_fingerprint_for_contract(
13419 &plan.scan_options,
13420 text_index_options(plan.config.as_ref(), None),
13421 #[cfg(feature = "optional-parser-supervisor")]
13422 &plan.optional_parser_selection,
13423 "19",
13424 PRE_QUALIFIED_OWNER_DIGEST,
13425 );
13426 let identity_projection_fingerprint = index_derivation_fingerprint_for_contract(
13427 &plan.scan_options,
13428 text_index_options(plan.config.as_ref(), None),
13429 #[cfg(feature = "optional-parser-supervisor")]
13430 &plan.optional_parser_selection,
13431 "18",
13432 PRE_IDENTITY_NEIGHBOR_DIGEST,
13433 );
13434 let accessor_projection_fingerprint = index_derivation_fingerprint_for_contract(
13435 &plan.scan_options,
13436 text_index_options(plan.config.as_ref(), None),
13437 #[cfg(feature = "optional-parser-supervisor")]
13438 &plan.optional_parser_selection,
13439 "17",
13440 PRE_ACCESSOR_PARAMETER_DIGEST,
13441 );
13442 let member_projection_fingerprint = index_derivation_fingerprint_for_contract(
13443 &plan.scan_options,
13444 text_index_options(plan.config.as_ref(), None),
13445 #[cfg(feature = "optional-parser-supervisor")]
13446 &plan.optional_parser_selection,
13447 "16",
13448 PRE_MEMBER_OWNERSHIP_DIGEST,
13449 );
13450 let instance_field_projection_fingerprint = index_derivation_fingerprint_for_contract(
13451 &plan.scan_options,
13452 text_index_options(plan.config.as_ref(), None),
13453 #[cfg(feature = "optional-parser-supervisor")]
13454 &plan.optional_parser_selection,
13455 "15",
13456 PRE_INSTANCE_FIELD_DIGEST,
13457 );
13458 let container_projection_fingerprint = index_derivation_fingerprint_for_contract(
13459 &plan.scan_options,
13460 text_index_options(plan.config.as_ref(), None),
13461 #[cfg(feature = "optional-parser-supervisor")]
13462 &plan.optional_parser_selection,
13463 "14",
13464 PRE_REOPENED_CONTAINER_DIGEST,
13465 );
13466 let arrow_projection_fingerprint = index_derivation_fingerprint_for_contract(
13467 &plan.scan_options,
13468 text_index_options(plan.config.as_ref(), None),
13469 #[cfg(feature = "optional-parser-supervisor")]
13470 &plan.optional_parser_selection,
13471 "13",
13472 PRE_ARROW_CONTEXT_DIGEST,
13473 );
13474 let source_context_projection_fingerprint = index_derivation_fingerprint_for_contract(
13475 &plan.scan_options,
13476 text_index_options(plan.config.as_ref(), None),
13477 #[cfg(feature = "optional-parser-supervisor")]
13478 &plan.optional_parser_selection,
13479 "12",
13480 PRE_SOURCE_CONTEXT_DIGEST,
13481 );
13482 let generator_projection_fingerprint = index_derivation_fingerprint_for_contract(
13483 &plan.scan_options,
13484 text_index_options(plan.config.as_ref(), None),
13485 #[cfg(feature = "optional-parser-supervisor")]
13486 &plan.optional_parser_selection,
13487 "11",
13488 PRE_GENERATOR_DECLARATION_DIGEST,
13489 );
13490 let function_environment_projection_fingerprint = index_derivation_fingerprint_for_contract(
13491 &plan.scan_options,
13492 text_index_options(plan.config.as_ref(), None),
13493 #[cfg(feature = "optional-parser-supervisor")]
13494 &plan.optional_parser_selection,
13495 "10",
13496 PRE_FUNCTION_ENVIRONMENT_DIGEST,
13497 );
13498 let recovery_projection_fingerprint = index_derivation_fingerprint_for_contract(
13499 &plan.scan_options,
13500 text_index_options(plan.config.as_ref(), None),
13501 #[cfg(feature = "optional-parser-supervisor")]
13502 &plan.optional_parser_selection,
13503 "9",
13504 PRE_RECOVERY_ADMISSION_DIGEST,
13505 );
13506 let var_projection_fingerprint = index_derivation_fingerprint_for_contract(
13507 &plan.scan_options,
13508 text_index_options(plan.config.as_ref(), None),
13509 #[cfg(feature = "optional-parser-supervisor")]
13510 &plan.optional_parser_selection,
13511 "8",
13512 PRE_VAR_RESERVATION_DIGEST,
13513 );
13514 let directive_projection_fingerprint = index_derivation_fingerprint_for_contract(
13515 &plan.scan_options,
13516 text_index_options(plan.config.as_ref(), None),
13517 #[cfg(feature = "optional-parser-supervisor")]
13518 &plan.optional_parser_selection,
13519 "7",
13520 PRE_DIRECTIVE_BINDING_DIGEST,
13521 );
13522 let deferred_projection_fingerprint = index_derivation_fingerprint_for_contract(
13523 &plan.scan_options,
13524 text_index_options(plan.config.as_ref(), None),
13525 #[cfg(feature = "optional-parser-supervisor")]
13526 &plan.optional_parser_selection,
13527 "6",
13528 PRE_OCCURRENCE_PROJECTION_DIGEST,
13529 );
13530 let occurrence_projection_fingerprint = index_derivation_fingerprint_for_contract(
13531 &plan.scan_options,
13532 text_index_options(plan.config.as_ref(), None),
13533 #[cfg(feature = "optional-parser-supervisor")]
13534 &plan.optional_parser_selection,
13535 "5",
13536 PRE_OCCURRENCE_PROJECTION_DIGEST,
13537 );
13538 let binding_projection_fingerprint = index_derivation_fingerprint_for_contract(
13539 &plan.scan_options,
13540 text_index_options(plan.config.as_ref(), None),
13541 #[cfg(feature = "optional-parser-supervisor")]
13542 &plan.optional_parser_selection,
13543 "4",
13544 PRE_BINDING_OWNERSHIP_DIGEST,
13545 );
13546 let prior_semantic_fingerprint = index_derivation_fingerprint_for_contract(
13547 &plan.scan_options,
13548 text_index_options(plan.config.as_ref(), None),
13549 #[cfg(feature = "optional-parser-supervisor")]
13550 &plan.optional_parser_selection,
13551 INDEX_DERIVATION_CONTRACT_VERSION,
13552 PRE_MODULE_CALLBACK_DIGEST,
13553 );
13554 let binding_semantic_fingerprint = index_derivation_fingerprint_for_contract(
13555 &plan.scan_options,
13556 text_index_options(plan.config.as_ref(), None),
13557 #[cfg(feature = "optional-parser-supervisor")]
13558 &plan.optional_parser_selection,
13559 INDEX_DERIVATION_CONTRACT_VERSION,
13560 PRE_BINDING_OWNERSHIP_DIGEST,
13561 );
13562 let stable_fingerprint = index_derivation_fingerprint_for_contract(
13563 &plan.scan_options,
13564 text_index_options(plan.config.as_ref(), None),
13565 #[cfg(feature = "optional-parser-supervisor")]
13566 &plan.optional_parser_selection,
13567 "3",
13568 STABLE_050_SEMANTIC_DIGEST,
13569 );
13570 if prior_projection_fingerprint == current_fingerprint
13571 || cargo_projection_fingerprint == current_fingerprint
13572 || go_clause_projection_fingerprint == current_fingerprint
13573 || comprehension_projection_fingerprint == current_fingerprint
13574 || qualified_owner_projection_fingerprint == current_fingerprint
13575 || identity_projection_fingerprint == current_fingerprint
13576 || accessor_projection_fingerprint == current_fingerprint
13577 || member_projection_fingerprint == current_fingerprint
13578 || instance_field_projection_fingerprint == current_fingerprint
13579 || container_projection_fingerprint == current_fingerprint
13580 || arrow_projection_fingerprint == current_fingerprint
13581 || source_context_projection_fingerprint == current_fingerprint
13582 || generator_projection_fingerprint == current_fingerprint
13583 || function_environment_projection_fingerprint == current_fingerprint
13584 || recovery_projection_fingerprint == current_fingerprint
13585 || var_projection_fingerprint == current_fingerprint
13586 || directive_projection_fingerprint == current_fingerprint
13587 || deferred_projection_fingerprint == current_fingerprint
13588 || occurrence_projection_fingerprint == current_fingerprint
13589 || binding_projection_fingerprint == current_fingerprint
13590 || prior_semantic_fingerprint == current_fingerprint
13591 || binding_semantic_fingerprint == current_fingerprint
13592 || stable_fingerprint == current_fingerprint
13593 {
13594 return Err(io::Error::other(
13595 "projection or semantic contract revision did not change the derivation fingerprint",
13596 )
13597 .into());
13598 }
13599
13600 let prior_control = standalone_index_work_control();
13601 let mut prior_batch = stage_full_index_publication(
13602 &store,
13603 &plan,
13604 &symbol_options,
13605 true,
13606 false,
13607 &prior_control,
13608 )?;
13609 prior_batch.contract_fingerprint = prior_projection_fingerprint;
13612 publish_index_batch(&mut store, prior_batch, &prior_control)?;
13613 if publication_contract_matches(&store, &plan)? {
13614 return Err(io::Error::other(
13615 "prior semantic contract unexpectedly matched the current plan",
13616 )
13617 .into());
13618 }
13619 let Err(CliError::VerificationIncomplete(stale_contract)) =
13620 verify_index_publication(&store, &plan)
13621 else {
13622 return Err(io::Error::other(
13623 "stale document projection crossed the publication read boundary",
13624 )
13625 .into());
13626 };
13627 require_eq(
13628 &stale_contract.reason,
13629 &IndexVerificationReason::PublicationContractMismatch,
13630 "stale document projection read reason",
13631 )?;
13632
13633 let stale_generation = store
13634 .index_publication()?
13635 .ok_or_else(|| io::Error::other("stale publication missing"))?
13636 .generation;
13637 let control = standalone_index_work_control();
13638 let before_repair = store.index_publication()?;
13639 let before_identity = store.project_instance_id()?;
13640 let source_paths = vec![
13641 "src/config.rs".to_string(),
13642 "src/handler.rs".to_string(),
13643 "src/router.rs".to_string(),
13644 ];
13645 let before_graphs = store.load_symbol_graphs_for_paths(&source_paths)?;
13646 let call_query = RepositoryGraphRelationQuery::Family {
13647 relation: GraphRelationKind::Legacy(RelationKind::Calls),
13648 };
13649 let before_calls = store
13650 .repository_graph_relations(call_query.clone(), 100)?
13651 .rows;
13652 let repair =
13653 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
13654 let cancelled = standalone_index_work_control();
13656 cancelled.cancel();
13657 if !matches!(
13658 publish_index_batch(&mut store, repair, &cancelled),
13659 Err(CliError::IndexWork(IndexWorkFailure::Cancelled { .. }))
13660 ) {
13661 return Err(io::Error::other("cancelled contract repair was not refused").into());
13662 }
13663 require_eq(
13664 &store.index_publication()?,
13665 &before_repair,
13666 "cancelled repair publication",
13667 )?;
13668 require_eq(
13669 &store.project_instance_id()?,
13670 &before_identity,
13671 "cancelled repair identity",
13672 )?;
13673 require_eq(
13674 &store.load_symbol_graphs_for_paths(&source_paths)?,
13675 &before_graphs,
13676 "cancelled repair raw symbol graphs",
13677 )?;
13678 require_eq(
13679 &store.repository_graph_relations(call_query, 100)?.rows,
13680 &before_calls,
13681 "cancelled repair canonical calls",
13682 )?;
13683 refresh_index_controlled(&mut store, &plan, &symbol_options, &control)?;
13684 let refreshed = store
13685 .index_publication()?
13686 .ok_or_else(|| io::Error::other("refreshed publication missing"))?;
13687 if refreshed.generation <= stale_generation
13688 || refreshed.contract_fingerprint.as_deref() != Some(current_fingerprint.as_str())
13689 {
13690 return Err(io::Error::other(
13691 "semantic contract mismatch did not force a current full publication",
13692 )
13693 .into());
13694 }
13695 let document_relations = store.repository_graph_relations(
13696 RepositoryGraphRelationQuery::Family {
13697 relation: GraphRelationKind::Extended(ExtendedRelationKind::Documents),
13698 },
13699 10,
13700 )?;
13701 let document_source_is_file = document_relations.rows.iter().any(|relation| {
13702 store
13703 .repository_graph_entity(relation.source())
13704 .ok()
13705 .flatten()
13706 .is_some_and(|source| {
13707 matches!(
13708 source.selector(),
13709 EntitySelector::File { path } if path.as_str() == "docs/guide.md"
13710 )
13711 })
13712 });
13713 if !document_source_is_file {
13714 return Err(io::Error::other(
13715 "full contract refresh did not republish the document file as source",
13716 )
13717 .into());
13718 }
13719 let graphs = store.load_symbol_graphs_for_paths(&["src/router.rs".to_string()])?;
13720 if !graphs
13721 .iter()
13722 .flat_map(|graph| &graph.relations)
13723 .any(|relation| {
13724 relation.kind == projectatlas_core::symbols::RelationKind::Calls
13725 && relation.target_name == "handler::health_response"
13726 })
13727 {
13728 return Err(io::Error::other(
13729 "semantic refresh did not publish the comparison-then callback edge",
13730 )
13731 .into());
13732 }
13733 Ok(())
13734 }
13735
13736 #[cfg(feature = "optional-parser-supervisor")]
13737 #[test]
13738 fn optional_parser_selection_changes_derivation_and_preserves_prior_generation_on_failure()
13739 -> Result<(), Box<dyn Error>> {
13740 let temp = tempfile::tempdir()?;
13741 let atlas_dir = temp.path().join(".projectatlas");
13742 fs::create_dir_all(&atlas_dir)?;
13743 let source_path = temp.path().join("main.awk");
13744 fs::write(&source_path, "BEGIN { print \"atlas\" }\n")?;
13745 let inactive_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13746 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
13747 let db_path = atlas_dir.join("projectatlas.db");
13748 let mut store = open_atlas_store_for_project(&db_path, &inactive_plan.root)?;
13749 run_scan_pipeline(&mut store, &inactive_plan, &symbol_options)?;
13750 if inactive_plan.scan_options.admit_optional_languages {
13751 return Err(io::Error::other(
13752 "inactive optional pack admitted catalog languages into the scan policy",
13753 )
13754 .into());
13755 }
13756 let inactive_optional = store
13757 .load_node_by_path("main.awk")?
13758 .ok_or_else(|| io::Error::other("inactive optional source node missing"))?;
13759 if inactive_optional.node.language.is_some() {
13760 return Err(io::Error::other(
13761 "inactive optional extension received a catalog language assignment",
13762 )
13763 .into());
13764 }
13765 let before = store
13766 .index_publication()?
13767 .ok_or_else(|| io::Error::other("initial publication missing"))?;
13768
13769 let selection_path = temp.path().join(repo_path_to_native(
13770 OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH,
13771 ));
13772 fs::write(
13773 &selection_path,
13774 serde_json::to_vec(&json!({
13775 "schema_version": 1,
13776 "pack_id": "broad-parser",
13777 "selected": {
13778 "projectatlas_version": OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION,
13779 "artifact": "a".repeat(64),
13780 }
13781 }))?,
13782 )?;
13783 let selected_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13784 if !selected_plan.scan_options.admit_optional_languages {
13785 return Err(io::Error::other(
13786 "selected optional pack did not enable catalog language admission",
13787 )
13788 .into());
13789 }
13790 if inactive_plan.publication_contract_fingerprint()
13791 == selected_plan.publication_contract_fingerprint()
13792 {
13793 return Err(io::Error::other(
13794 "optional parser selection did not change the derivation contract",
13795 )
13796 .into());
13797 }
13798 if publication_contract_matches(&store, &selected_plan)? {
13799 return Err(io::Error::other(
13800 "selected optional artifact unexpectedly matched the inactive publication",
13801 )
13802 .into());
13803 }
13804 if !watch_path_requires_full_scan(temp.path(), &selection_path) {
13805 return Err(io::Error::other(
13806 "optional parser selection event did not require a full refresh",
13807 )
13808 .into());
13809 }
13810
13811 let mut changes = WatchChangeSet::default();
13812 changes.paths.insert(source_path);
13813 let result =
13814 refresh_index_for_changes(&mut store, &selected_plan, &changes, &symbol_options);
13815 if !matches!(result, Err(CliError::ParserPack(_))) {
13816 return Err(io::Error::other(
13817 "missing selected artifact did not fail before publication",
13818 )
13819 .into());
13820 }
13821 require_eq(
13822 &store
13823 .index_publication()?
13824 .ok_or_else(|| io::Error::other("publication disappeared after failure"))?
13825 .generation,
13826 &before.generation,
13827 "generation after selected optional artifact failure",
13828 )?;
13829
13830 fs::remove_file(selection_path)?;
13831 let disabled_plan = selected_plan.reload()?;
13832 if disabled_plan.scan_options.admit_optional_languages {
13833 return Err(io::Error::other(
13834 "disabled optional pack retained catalog language admission",
13835 )
13836 .into());
13837 }
13838 require_eq(
13839 &disabled_plan.publication_contract_fingerprint(),
13840 &inactive_plan.publication_contract_fingerprint(),
13841 "disabled optional parser derivation contract",
13842 )?;
13843
13844 let stale_graph = SymbolGraph {
13845 path: "main.awk".to_string(),
13846 language: Some("awk".to_string()),
13847 parser: ParserKind::Fallback,
13848 symbols: Vec::new(),
13849 relations: Vec::new(),
13850 };
13851 let stale_metadata = SourceParseMetadata {
13852 path: stale_graph.path.clone(),
13853 language: stale_graph.language.clone(),
13854 parser: ParserKind::TreeSitter,
13855 symbol_count: 0,
13856 relation_count: 0,
13857 };
13858 store.replace_symbol_graph_with_metadata(&stale_graph, &stale_metadata)?;
13859 let selected_publication = store.begin_index_publication_from(
13860 &selected_plan.publication_contract_fingerprint(),
13861 before.generation,
13862 )?;
13863 selected_publication.complete()?;
13864
13865 refresh_index(&mut store, &disabled_plan, &symbol_options)?;
13866 require_eq(
13867 &store.load_source_parse_metadata("main.awk")?,
13868 &None,
13869 "disabled optional parser metadata",
13870 )?;
13871 if !publication_contract_matches(&store, &disabled_plan)? {
13872 return Err(io::Error::other(
13873 "disabled optional parser refresh did not publish its derivation contract",
13874 )
13875 .into());
13876 }
13877 Ok(())
13878 }
13879
13880 #[test]
13881 fn symbol_projection_refresh_republishes_normalized_graph_at_one_generation()
13882 -> Result<(), Box<dyn Error>> {
13883 let temp = tempfile::tempdir()?;
13884 let atlas_dir = temp.path().join(".projectatlas");
13885 fs::create_dir_all(&atlas_dir)?;
13886 fs::write(
13887 temp.path().join("lib.rs"),
13888 "pub fn caller() { target(); }\npub fn target() {}\n",
13889 )?;
13890 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13891 let mut store =
13892 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
13893 refresh_index(
13894 &mut store,
13895 &plan,
13896 &SymbolBuildOptions::new(1_024, Some(1), None),
13897 )?;
13898 let before = store
13899 .index_publication()?
13900 .ok_or_else(|| io::Error::other("initial publication missing"))?;
13901 let relation_query = RepositoryGraphRelationQuery::Family {
13902 relation: GraphRelationKind::Legacy(RelationKind::Calls),
13903 };
13904 require_eq(
13905 &store
13906 .repository_graph_relations(relation_query.clone(), 10)?
13907 .rows
13908 .len(),
13909 &1,
13910 "initial normalized call relation",
13911 )?;
13912
13913 run_symbol_build_pipeline(
13914 &mut store,
13915 &plan,
13916 &SymbolBuildOptions::new(1, Some(1), None),
13917 None,
13918 )?;
13919
13920 let after = store
13921 .index_publication()?
13922 .ok_or_else(|| io::Error::other("symbol publication missing"))?;
13923 require_eq(
13924 &after.generation,
13925 &before
13926 .generation
13927 .checked_next()
13928 .ok_or_else(|| io::Error::other("publication generation overflowed"))?,
13929 "symbol and graph publication generation",
13930 )?;
13931 require_eq(
13932 &store
13933 .repository_graph_relations(relation_query, 10)?
13934 .rows
13935 .len(),
13936 &0,
13937 "cleared symbol relation projection",
13938 )?;
13939 let project = store
13940 .project_instance_id()?
13941 .ok_or_else(|| io::Error::other("project identity missing"))?;
13942 let path = RepositoryNodePath::new(Path::new("lib.rs"))?;
13943 let entities = store.repository_graph_entities_by_path(project, &path, 10)?;
13944 require_eq(
13945 &entities
13946 .rows
13947 .iter()
13948 .all(|entity| entity.generation() == after.generation),
13949 &true,
13950 "symbol refresh normalized graph generation",
13951 )?;
13952 Ok(())
13953 }
13954
13955 #[test]
13956 fn unchanged_full_and_incremental_refreshes_do_not_advance_generation()
13957 -> Result<(), Box<dyn Error>> {
13958 let temp = tempfile::tempdir()?;
13959 let atlas_dir = temp.path().join(".projectatlas");
13960 fs::create_dir_all(&atlas_dir)?;
13961 let source_path = temp.path().join("lib.rs");
13962 fs::write(&source_path, "fn stable() {}\n")?;
13963 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
13964 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
13965 let mut store =
13966 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
13967 refresh_index(&mut store, &plan, &symbol_options)?;
13968 let normal_read_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
13969 verify_index_publication(&store, &normal_read_plan)?;
13970 let before = store
13971 .index_publication()?
13972 .ok_or_else(|| io::Error::other("initial publication missing"))?;
13973 let project = store
13974 .project_instance_id()?
13975 .ok_or_else(|| io::Error::other("project identity missing"))?;
13976 let abandoned_full_stage = atlas_dir.join("graph-stage-full-noop");
13977 fs::create_dir(&abandoned_full_stage)?;
13978 drop(AtlasStore::create_repository_graph_staging(
13979 &abandoned_full_stage.join("projectatlas.db"),
13980 &plan.root,
13981 project,
13982 )?);
13983 let full_report = refresh_index(&mut store, &plan, &symbol_options)?;
13984 let after_full = store
13985 .index_publication()?
13986 .ok_or_else(|| io::Error::other("publication missing after full no-op refresh"))?;
13987 require_eq(
13988 &after_full.generation,
13989 &before.generation,
13990 "full no-op publication generation",
13991 )?;
13992 require_eq(
13993 &full_report.text_index.candidates,
13994 &0,
13995 "full no-op text candidates",
13996 )?;
13997 require_eq(
13998 &full_report.structural_summaries.candidates,
13999 &0,
14000 "full no-op summary candidates",
14001 )?;
14002 require_eq(
14003 &full_report.symbols.candidates,
14004 &0,
14005 "full no-op symbol candidates",
14006 )?;
14007 require_eq(
14008 &full_report.symbols.max_workers,
14009 &0,
14010 "full no-op symbol workers",
14011 )?;
14012 require_eq(
14013 &abandoned_full_stage.exists(),
14014 &false,
14015 "full no-op abandoned graph stage",
14016 )?;
14017 let abandoned_incremental_stage = atlas_dir.join("graph-stage-incremental-noop");
14018 fs::create_dir(&abandoned_incremental_stage)?;
14019 drop(AtlasStore::create_repository_graph_staging(
14020 &abandoned_incremental_stage.join("projectatlas.db"),
14021 &plan.root,
14022 project,
14023 )?);
14024 let mut changes = WatchChangeSet::default();
14025 changes.paths.insert(source_path);
14026
14027 let report = refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
14028 let after = store
14029 .index_publication()?
14030 .ok_or_else(|| io::Error::other("publication missing after no-op refresh"))?;
14031
14032 require_eq(
14033 &after.generation,
14034 &before.generation,
14035 "no-op publication generation",
14036 )?;
14037 require_eq(&report.text_index.candidates, &0, "no-op text candidates")?;
14038 require_eq(
14039 &report.structural_summaries.candidates,
14040 &0,
14041 "no-op summary candidates",
14042 )?;
14043 require_eq(&report.symbols.candidates, &0, "no-op symbol candidates")?;
14044 require_eq(&report.symbols.max_workers, &0, "no-op symbol workers")?;
14045 require_eq(
14046 &abandoned_incremental_stage.exists(),
14047 &false,
14048 "incremental no-op abandoned graph stage",
14049 )?;
14050 Ok(())
14051 }
14052
14053 #[test]
14054 fn full_fallback_consumes_captured_discovery_and_rejects_later_changes()
14055 -> Result<(), Box<dyn Error>> {
14056 let temp = tempfile::tempdir()?;
14057 fs::write(temp.path().join("lib.rs"), "pub fn baseline() {}\n")?;
14058 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
14059 let options = SymbolBuildOptions::new(1_024, Some(1), None);
14060 let mut store = AtlasStore::in_memory()?;
14061 store.set_project_root(&plan.root)?;
14062 run_scan_pipeline(&mut store, &plan, &options)?;
14063 store.set_purpose(
14064 "lib.rs",
14065 "Expose the library entry point",
14066 PurposeSource::Agent,
14067 )?;
14068 let identity = store.project_instance_id()?;
14069 let publication = store.index_publication()?;
14070 fs::create_dir(temp.path().join("added"))?;
14071 fs::write(temp.path().join("added/first.rs"), "pub fn first() {}\n")?;
14072 let control = standalone_index_work_control();
14073 let assessment =
14074 detect_index_freshness_controlled(&store, &plan, ScanLimits::default(), &control)?;
14075 require_eq(
14076 &assessment
14077 .delta
14078 .as_ref()
14079 .is_some_and(|delta| delta.requires_full_scan),
14080 &true,
14081 "folder change selects full staging",
14082 )?;
14083 fs::write(temp.path().join("late.rs"), "pub fn late() {}\n")?;
14084 require_eq(
14086 &matches!(
14087 refresh_index_from_assessment_controlled(
14088 &mut store, &plan, &options, assessment, &control,
14089 ),
14090 Err(CliError::RefreshRequired(report))
14091 if report.changed == 1 && report.sample_paths == ["late.rs"]
14092 ),
14093 &true,
14094 "captured full fallback refuses late source",
14095 )?;
14096 require_eq(
14097 &store.index_publication()?,
14098 &publication,
14099 "unchanged publication",
14100 )?;
14101 require_eq(
14102 &store.project_instance_id()?,
14103 &identity,
14104 "preserved identity",
14105 )?;
14106 require_eq(
14107 &store.load_symbols(Some("late.rs"), None, 10)?.is_empty(),
14108 &true,
14109 "late source not published",
14110 )?;
14111 let refreshed = refresh_index(&mut store, &plan, &options)?;
14112 require_eq(
14113 &refreshed.symbols.parsed,
14114 &2,
14115 "retry parses both added sources",
14116 )?;
14117 require_eq(
14118 &refreshed.symbols.unchanged,
14119 &1,
14120 "retry reuses baseline source",
14121 )?;
14122 require_eq(
14123 &store.load_symbols(Some("late.rs"), None, 10)?.len(),
14124 &1,
14125 "retry publishes late symbol",
14126 )?;
14127 require_eq(
14128 &store.project_instance_id()?,
14129 &identity,
14130 "preserved identity",
14131 )?;
14132 require_eq(
14133 &store
14134 .load_node_by_path("lib.rs")?
14135 .and_then(|node| node.purpose.purpose),
14136 &Some("Expose the library entry point".to_string()),
14137 "preserved purpose",
14138 )?;
14139 Ok(())
14140 }
14141
14142 #[test]
14143 fn cold_refresh_applies_mixed_changes_and_preserves_same_metadata_edits()
14144 -> Result<(), Box<dyn Error>> {
14145 let temp = tempfile::tempdir()?;
14146 let root = temp.path();
14147 let changed = root.join("lib.rs");
14148 fs::write(&changed, "pub fn alpha() {}\n")?;
14149 fs::write(root.join("stable.rs"), "pub fn stable() {}\n")?;
14150 fs::write(root.join("removed.rs"), "pub fn removed() {}\n")?;
14151 fs::write(root.join("moved.rs"), "pub fn moved() {}\n")?;
14152 let plan = ScanRuntimePlan::for_path(None, root, None)?;
14153 let options = SymbolBuildOptions::new(1_024, Some(1), None);
14154 let database = root.join(".projectatlas/projectatlas.db");
14155 let mut store = open_atlas_store_for_project(&database, root)?;
14156 run_scan_pipeline(&mut store, &plan, &options)?;
14157 store.set_purpose(
14158 "lib.rs",
14159 "Expose the library entry point",
14160 PurposeSource::Agent,
14161 )?;
14162 let generation = publication_base_generation(&store)?;
14163 let identity = store.project_instance_id()?;
14164 drop(store);
14165
14166 let modified = fs::metadata(&changed)?.modified()?;
14167 fs::write(&changed, "pub fn omega() {}\n")?;
14168 fs::File::options()
14169 .write(true)
14170 .open(&changed)?
14171 .set_times(fs::FileTimes::new().set_modified(modified))?;
14172 fs::remove_file(root.join("removed.rs"))?;
14173 fs::rename(root.join("moved.rs"), root.join("renamed.rs"))?;
14174 fs::write(root.join("added.rs"), "pub fn added() {}\n")?;
14175
14176 let mut store = open_atlas_store_for_project(&database, root)?;
14178 let report = refresh_index(&mut store, &plan, &options)?;
14179 require_eq(
14180 &report.symbols.parsed,
14181 &3,
14182 "only current changed files parsed",
14183 )?;
14184 require_eq(
14185 &report.text_index.candidates,
14186 &3,
14187 "only changed text staged",
14188 )?;
14189 require_eq(
14190 &store.project_instance_id()?,
14191 &identity,
14192 "native identity retained",
14193 )?;
14194 require_eq(
14195 &publication_base_generation(&store)?,
14196 &generation
14197 .checked_next()
14198 .ok_or_else(|| io::Error::other("generation overflow"))?,
14199 "one complete mixed-change publication",
14200 )?;
14201 let library = store
14202 .load_node_by_path("lib.rs")?
14203 .ok_or_else(|| io::Error::other("updated library missing"))?;
14204 require_eq(
14205 &library.purpose.purpose.as_deref(),
14206 &Some("Expose the library entry point"),
14207 "approved purpose retained",
14208 )?;
14209 require_eq(
14210 &store.load_file_text("lib.rs")?.map(|text| text.content),
14211 &Some("pub fn omega() {}\n".to_string()),
14212 "same-metadata edit indexed",
14213 )?;
14214 for path in ["removed.rs", "moved.rs"] {
14215 require_eq(
14216 &store.load_node_by_path(path)?.is_none(),
14217 &true,
14218 "removed source absent",
14219 )?;
14220 require_eq(
14221 &store.load_file_text(path)?.is_none(),
14222 &true,
14223 "removed text absent",
14224 )?;
14225 }
14226 require_eq(
14227 &store.load_node_by_path("stable.rs")?.is_some(),
14228 &true,
14229 "unrelated file retained",
14230 )?;
14231 verify_index_freshness(&store, root, None)?;
14232 Ok(())
14233 }
14234
14235 #[test]
14236 fn source_assessment_rejects_policy_publication_between_capture_and_comparison()
14237 -> Result<(), Box<dyn Error>> {
14238 let temp = tempfile::tempdir()?;
14239 let root = temp.path();
14240 fs::write(root.join("lib.rs"), "pub fn stable() {}\n")?;
14241 let database = root.join(".projectatlas/projectatlas.db");
14242 let plan = ScanRuntimePlan::for_path(None, root, None)?;
14243 let options = SymbolBuildOptions::new(1_024, Some(1), None);
14244 let mut store = open_atlas_store_for_project(&database, root)?;
14245 run_scan_pipeline(&mut store, &plan, &options)?;
14246 let generation = verify_index_publication(&store, &plan)?;
14247 let mut competing = open_atlas_store_for_project(&database, root)?;
14249 competing
14250 .begin_index_publication_from("different-derivation-contract", generation)?
14251 .complete()?;
14252 let winning = competing.index_publication()?;
14253 let result = assess_source_against_publication(
14254 &store,
14255 &plan,
14256 ScanLimits::default(),
14257 &standalone_index_work_control(),
14258 generation,
14259 SourceVerificationWork::default(),
14260 );
14261 require_eq(
14262 &matches!(result, Err(CliError::Db(
14263 DbError::PublicationBaseGenerationChanged { expected, .. }
14264 )) if expected == generation),
14265 &true,
14266 "concurrent policy publication refused",
14267 )?;
14268 require_eq(
14269 &store.index_publication()?,
14270 &winning,
14271 "winning publication untouched",
14272 )?;
14273 let repaired = refresh_index(&mut store, &plan, &options)?;
14274 require_eq(
14275 &(repaired.text_index.candidates > 0),
14276 &true,
14277 "mismatched policy cannot be reported as no-op",
14278 )?;
14279 verify_index_publication(&store, &plan)?;
14280 Ok(())
14281 }
14282
14283 #[test]
14284 fn captured_source_rejects_changed_identity_contract_and_generation()
14285 -> Result<(), Box<dyn Error>> {
14286 let temp = tempfile::tempdir()?;
14287 let root = temp.path();
14288 fs::write(root.join("lib.rs"), "pub fn initial() {}\n")?;
14289 let plan = ScanRuntimePlan::for_path(None, root, None)?;
14290 let options = SymbolBuildOptions::new(1_024, Some(1), None);
14291 let mut store =
14292 open_atlas_store_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
14293 run_scan_pipeline(&mut store, &plan, &options)?;
14294 let mut changes = WatchChangeSet::default();
14295 changes.paths.insert(root.join("lib.rs"));
14296 fs::write(root.join("lib.rs"), "pub fn updated() {}\n")?;
14297 let control = standalone_index_work_control();
14298 for mismatch in ["root", "project", "contract", "generation"] {
14299 let mut source =
14300 detect_index_freshness_controlled(&store, &plan, ScanLimits::default(), &control)?
14301 .source;
14302 match mismatch {
14303 "root" => source.root = root.join("different"),
14304 "project" => {
14305 let alternate = ProjectInstanceId::from_bytes([1; 16])?;
14306 source.project_instance_id = if source.project_instance_id == alternate {
14307 ProjectInstanceId::from_bytes([2; 16])?
14308 } else {
14309 alternate
14310 };
14311 }
14312 "contract" => source.contract_fingerprint.push_str("-different"),
14313 "generation" => {
14314 run_scan_pipeline(&mut store, &plan, &options)?;
14315 }
14316 _ => return Err(io::Error::other("unexpected mismatch fixture").into()),
14317 }
14318 let before = store.index_publication()?;
14319 let result = refresh_index_for_changes_from_source_controlled(
14320 &mut store,
14321 &plan,
14322 &changes,
14323 &options,
14324 Some(source),
14325 &control,
14326 );
14327 let refused = match mismatch {
14328 "project" => matches!(result, Err(CliError::InvalidInput(_))),
14329 "generation" => matches!(
14330 result,
14331 Err(CliError::Db(
14332 DbError::PublicationBaseGenerationChanged { .. }
14333 ))
14334 ),
14335 _ => matches!(result, Err(CliError::RefreshRequired(_))),
14336 };
14337 require_eq(&refused, &true, "captured source identity mismatch refused")?;
14338 require_eq(
14339 &store.index_publication()?,
14340 &before,
14341 "last publication retained",
14342 )?;
14343 }
14344 Ok(())
14345 }
14346
14347 #[test]
14348 fn captured_source_rejects_late_unrelated_edit_without_publication()
14349 -> Result<(), Box<dyn Error>> {
14350 let temp = tempfile::tempdir()?;
14351 let root = temp.path();
14352 fs::write(root.join("lib.rs"), "pub fn initial() {}\n")?;
14353 fs::write(root.join("stable.rs"), "pub fn stable() {}\n")?;
14354 let plan = ScanRuntimePlan::for_path(None, root, None)?;
14355 let options = SymbolBuildOptions::new(1_024, Some(1), None);
14356 let mut store =
14357 open_atlas_store_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
14358 run_scan_pipeline(&mut store, &plan, &options)?;
14359 store.set_purpose(
14360 "lib.rs",
14361 "Expose reviewed library behavior",
14362 PurposeSource::Agent,
14363 )?;
14364 let before = store.index_publication()?;
14365 let identity = store.project_instance_id()?;
14366 let library = store.load_node_by_path("lib.rs")?;
14367 let text = store.load_file_text("lib.rs")?.map(|row| row.content);
14368 fs::write(root.join("lib.rs"), "pub fn updated() {}\n")?;
14369 let control = standalone_index_work_control();
14370 let source =
14371 detect_index_freshness_controlled(&store, &plan, ScanLimits::default(), &control)?
14372 .source;
14373 fs::write(
14374 root.join("stable.rs"),
14375 "pub fn changed_after_discovery() {}\n",
14376 )?;
14377 let mut changes = WatchChangeSet::default();
14378 changes.paths.insert(root.join("lib.rs"));
14379 let result = refresh_index_for_changes_from_source_controlled(
14380 &mut store,
14381 &plan,
14382 &changes,
14383 &options,
14384 Some(source),
14385 &control,
14386 );
14387 require_eq(
14388 &matches!(result, Err(CliError::RefreshRequired(_))),
14389 &true,
14390 "late unrelated edit refused",
14391 )?;
14392 require_eq(
14393 &store.index_publication()?,
14394 &before,
14395 "last generation retained",
14396 )?;
14397 require_eq(
14398 &store.project_instance_id()?,
14399 &identity,
14400 "identity retained",
14401 )?;
14402 require_eq(
14403 &store.load_node_by_path("lib.rs")?,
14404 &library,
14405 "source and purpose retained",
14406 )?;
14407 require_eq(
14408 &store.load_file_text("lib.rs")?.map(|row| row.content),
14409 &text,
14410 "last valid text retained",
14411 )?;
14412 let report = refresh_index(&mut store, &plan, &options)?;
14413 require_eq(&report.symbols.parsed, &2, "retry reconciles both edits")?;
14414 verify_index_freshness(&store, root, None)?;
14415 Ok(())
14416 }
14417
14418 #[test]
14419 fn oversized_watch_batch_reconciles_complete_state_in_one_publication()
14420 -> Result<(), Box<dyn Error>> {
14421 let temp = tempfile::tempdir()?;
14422 let root = temp.path();
14423 fs::write(root.join("lib.rs"), "pub fn previous() {}\n")?;
14424 fs::write(root.join("stable.rs"), "pub fn stable() {}\n")?;
14425 let plan = ScanRuntimePlan::for_path(None, root, None)?;
14426 let options = SymbolBuildOptions::new(1_024, Some(1), None);
14427 let mut store =
14428 open_atlas_store_for_project(&root.join(".projectatlas/projectatlas.db"), root)?;
14429 run_scan_pipeline(&mut store, &plan, &options)?;
14430 store.set_purpose(
14431 "lib.rs",
14432 "Expose the library entry point",
14433 PurposeSource::Agent,
14434 )?;
14435 let before = publication_base_generation(&store)?;
14436 let identity = store.project_instance_id()?;
14437 fs::write(root.join("lib.rs"), "pub fn updated() {}\n")?;
14438 fs::write(root.join("late.rs"), "pub fn late() {}\n")?;
14439
14440 let changes = WatchChangeSet {
14444 requires_full_scan: false,
14445 document_paths: HashSet::new(),
14446 paths: (0..=MAX_INCREMENTAL_CHANGED_PATHS)
14447 .map(|index| root.join(format!("deleted_{index}.rs")))
14448 .chain(std::iter::once(root.join("lib.rs")))
14449 .collect(),
14450 };
14451 let report = refresh_index_for_changes(&mut store, &plan, &changes, &options)?;
14452 require_eq(
14453 &report.text_index.candidates,
14454 &3,
14455 "full fallback covers every current file",
14456 )?;
14457 require_eq(&report.symbols.candidates, &3, "complete symbol inventory")?;
14458 require_eq(
14459 &report.symbols.parsed,
14460 &2,
14461 "changed and newly discovered sources parsed",
14462 )?;
14463 require_eq(
14464 &report.symbols.unchanged,
14465 &1,
14466 "unrelated valid parser facts reused",
14467 )?;
14468 require_eq(
14469 &store.overview()?.files,
14470 &3,
14471 "deleted event paths remain absent",
14472 )?;
14473 require_eq(
14474 &store.load_file_text("lib.rs")?.map(|text| text.content),
14475 &Some("pub fn updated() {}\n".to_string()),
14476 "retained edit published",
14477 )?;
14478 require_eq(
14479 &store.load_file_text("late.rs")?.is_some(),
14480 &true,
14481 "addition outside event set discovered",
14482 )?;
14483 require_eq(
14484 &publication_base_generation(&store)?,
14485 &before
14486 .checked_next()
14487 .ok_or_else(|| io::Error::other("generation overflow"))?,
14488 "fallback publishes exactly one complete generation",
14489 )?;
14490 require_eq(
14491 &store.project_instance_id()?,
14492 &identity,
14493 "fallback preserves identity",
14494 )?;
14495 require_eq(
14496 &store
14497 .load_node_by_path("lib.rs")?
14498 .and_then(|node| node.purpose.purpose),
14499 &Some("Expose the library entry point".to_string()),
14500 "fallback preserves approved purpose",
14501 )?;
14502 verify_index_freshness(&store, root, None)?;
14503 Ok(())
14504 }
14505
14506 #[test]
14507 fn watcher_preserves_explicit_full_refresh_guidance() -> Result<(), Box<dyn Error>> {
14508 let temp = tempfile::tempdir()?;
14509 fs::write(temp.path().join("source.rs"), "pub fn source() {}\n")?;
14510 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
14511 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
14512 let database = temp.path().join(".projectatlas/projectatlas.db");
14513 let mut store = open_atlas_store_for_project(&database, &plan.root)?;
14514 refresh_index(&mut store, &plan, &symbol_options)?;
14515 let before = store.index_publication()?;
14516
14517 let error =
14518 run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |_| {
14519 Err(CliError::RefreshRequired(Box::new(
14520 index_policy_refresh_required(&plan.root),
14521 )))
14522 })
14523 .err()
14524 .ok_or_else(|| {
14525 io::Error::other("full-refresh guidance was hidden by polling fallback")
14526 })?;
14527 let CliError::RefreshRequired(report) = error else {
14528 return Err(io::Error::other(format!(
14529 "unexpected watcher error after full-refresh guidance: {error:?}"
14530 ))
14531 .into());
14532 };
14533 require_eq(
14534 &report.scope,
14535 &IndexRefreshScope::Full,
14536 "watcher full-refresh scope",
14537 )?;
14538 require_eq(
14539 &store.index_publication()?,
14540 &before,
14541 "watcher generation after full-refresh guidance",
14542 )?;
14543 Ok(())
14544 }
14545
14546 #[test]
14547 fn stopped_watcher_batch_preserves_last_valid_and_retries_one_generation()
14548 -> Result<(), Box<dyn Error>> {
14549 let temp = tempfile::tempdir()?;
14550 let atlas_dir = temp.path().join(".projectatlas");
14551 fs::create_dir_all(&atlas_dir)?;
14552 let reserved_purpose_path = atlas_dir.join("projectatlas-nonsource-files.toon");
14553 fs::write(&reserved_purpose_path, "nonsource_files[]:\n")?;
14554 let changed_path = temp.path().join("changed.rs");
14555 let deleted_path = temp.path().join("deleted.rs");
14556 let deleted_dir = temp.path().join("deleted");
14557 let deleted_descendant = deleted_dir.join("descendant.rs");
14558 fs::create_dir(&deleted_dir)?;
14559 fs::write(&changed_path, "pub fn before() {}\n")?;
14560 fs::write(&deleted_path, "pub fn removed() {}\n")?;
14561 fs::write(&deleted_descendant, "pub fn descendant() {}\n")?;
14562 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
14563 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
14564 let db_path = atlas_dir.join("projectatlas.db");
14565 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
14566 refresh_index(&mut store, &plan, &symbol_options)?;
14567 let before = store
14568 .index_publication()?
14569 .ok_or_else(|| io::Error::other("initial publication missing"))?;
14570 let before_identity = store
14571 .project_root_identity()?
14572 .ok_or_else(|| io::Error::other("initial project identity missing"))?;
14573 let before_node = store
14574 .load_node_by_path("changed.rs")?
14575 .ok_or_else(|| io::Error::other("initial changed node missing"))?;
14576 let reviewed_reserved_purpose = "Describe reviewed non-source atlas responsibilities.";
14577 store.set_purpose(
14578 ".projectatlas/projectatlas-nonsource-files.toon",
14579 reviewed_reserved_purpose,
14580 PurposeSource::Agent,
14581 )?;
14582 let old_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
14583 require_eq(
14584 &old_reader
14585 .index_publication()?
14586 .as_ref()
14587 .map(|state| state.generation),
14588 &Some(before.generation),
14589 "old reader generation before staged publication",
14590 )?;
14591 let old_text = old_reader
14592 .load_file_text("changed.rs")?
14593 .ok_or_else(|| io::Error::other("old reader text missing"))?;
14594
14595 fs::write(&changed_path, "pub fn after() {}\n")?;
14596 fs::remove_file(&deleted_path)?;
14597 fs::remove_dir_all(&deleted_dir)?;
14598 fs::write(&reserved_purpose_path, "nonsource_files[]:\n\n")?;
14599 let expired = IndexWorkControl::new(IndexCancellation::new(), Some(Duration::ZERO));
14600 if !matches!(
14601 refresh_index_controlled(&mut store, &plan, &symbol_options, &expired),
14602 Err(CliError::IndexWork(
14603 IndexWorkFailure::DeadlineExceeded { .. }
14604 ))
14605 ) {
14606 return Err(io::Error::other("expired refresh did not refuse before parsing").into());
14607 }
14608 require_eq(
14609 &store.index_publication()?.map(|state| state.generation),
14610 &Some(before.generation),
14611 "generation after pre-parser deadline refusal",
14612 )?;
14613 require_eq(
14614 &store.project_root_identity()?.as_ref(),
14615 &Some(&before_identity),
14616 "project identity after deadline refusal",
14617 )?;
14618 require_eq(
14619 &store.load_node_by_path("changed.rs")?.as_ref(),
14620 &Some(&before_node),
14621 "last-valid node after deadline refusal",
14622 )?;
14623 let retained_purpose = store
14624 .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
14625 .ok_or_else(|| io::Error::other("reviewed purpose missing after deadline refusal"))?;
14626 require_eq(
14627 &retained_purpose.purpose.purpose.as_deref(),
14628 &Some(reviewed_reserved_purpose),
14629 "reviewed purpose after deadline refusal",
14630 )?;
14631 require_eq(
14632 &retained_purpose.purpose.status,
14633 &PurposeStatus::Approved,
14634 "approved purpose state after deadline refusal",
14635 )?;
14636 let preparation_control = standalone_index_work_control();
14637 let staged_batch = stage_full_index_publication(
14638 &store,
14639 &plan,
14640 &symbol_options,
14641 true,
14642 false,
14643 &preparation_control,
14644 )?;
14645 revalidate_staged_publication_inputs_controlled(
14646 &plan,
14647 staged_batch.nodes.expected_nodes(),
14648 None,
14649 &preparation_control,
14650 )?;
14651 let IndexPublicationBatch {
14652 base_generation,
14653 contract_fingerprint,
14654 root,
14655 nodes,
14656 purpose_import: _,
14657 text_paths,
14658 text,
14659 content_classifications,
14660 symbols: _,
14661 graph: _,
14662 structural_summaries: _,
14663 } = staged_batch;
14664 let mut staged =
14665 store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
14666 staged.set_project_root(&root)?;
14667 let NodePublicationBatch::Full { nodes } = nodes else {
14668 return Err(io::Error::other("full staging returned an incremental batch").into());
14669 };
14670 staged.begin_scan_replacement()?;
14671 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
14672 staged.upsert_scan_node_batch(batch)?;
14673 }
14674 apply_file_content_classification_stage(
14675 &mut staged,
14676 &content_classifications,
14677 &preparation_control,
14678 )?;
14679 staged.finish_scan_replacement()?;
14680 let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
14681 late_cancel.cancel();
14682 let late_result = apply_text_index_stage(&mut staged, &text_paths, &text, &late_cancel);
14683 if !matches!(
14684 late_result,
14685 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
14686 stage: IndexWorkStage::Publication,
14687 }))
14688 ) {
14689 return Err(io::Error::other("late cancellation did not stop publication").into());
14690 }
14691 drop(staged);
14692 require_eq(
14693 &store
14694 .index_publication()?
14695 .as_ref()
14696 .map(|state| state.generation),
14697 &Some(before.generation),
14698 "generation after canceled publication",
14699 )?;
14700 require_eq(
14701 &store.load_node_by_path("changed.rs")?,
14702 &Some(before_node),
14703 "last-valid node after canceled publication",
14704 )?;
14705 let mut changes = WatchChangeSet::default();
14706 changes.paths.insert(changed_path);
14707 changes.paths.insert(deleted_path);
14708 changes.paths.insert(deleted_dir);
14709 changes.paths.insert(deleted_descendant);
14710 changes.paths.insert(reserved_purpose_path);
14711 let fallback_report =
14712 run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |store| {
14713 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
14714 canceled.cancel();
14715 let Err(error) = refresh_index_for_changes_controlled(
14716 store,
14717 &plan,
14718 &changes,
14719 &symbol_options,
14720 &canceled,
14721 ) else {
14722 return Err(CliError::InvalidInput(
14723 "canceled watcher batch unexpectedly succeeded".to_string(),
14724 ));
14725 };
14726 let generation = store.index_publication()?.map(|state| state.generation);
14727 if generation != Some(before.generation) {
14728 return Err(CliError::InvalidInput(
14729 "canceled watcher batch advanced publication before fallback".to_string(),
14730 ));
14731 }
14732 Err(error)
14733 })?;
14734 require_eq(
14735 &fallback_report.mode.as_str(),
14736 &WATCH_MODE_POLLING,
14737 "canceled notify batch fallback mode",
14738 )?;
14739 if fallback_report
14740 .fallback_reason
14741 .as_deref()
14742 .is_none_or(|reason| !reason.contains("canceled"))
14743 {
14744 return Err(io::Error::other(
14745 "polling fallback did not retain the notify failure reason",
14746 )
14747 .into());
14748 }
14749
14750 let after = store
14751 .index_publication()?
14752 .ok_or_else(|| io::Error::other("incremental publication missing"))?;
14753 require_eq(
14754 &after.generation,
14755 &before
14756 .generation
14757 .checked_next()
14758 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
14759 "one incremental publication generation",
14760 )?;
14761 require_eq(
14762 &store.load_node_by_path("deleted.rs")?.is_none(),
14763 &true,
14764 "deleted path is absent",
14765 )?;
14766 require_eq(
14767 &store
14768 .load_symbols(Some("deleted.rs"), Some("removed"), 10)?
14769 .is_empty(),
14770 &true,
14771 "deleted symbols are invalidated",
14772 )?;
14773 require_eq(
14774 &store.load_node_by_path("deleted/descendant.rs")?.is_none(),
14775 &true,
14776 "deleted descendant path is absent",
14777 )?;
14778 require_eq(
14779 &store
14780 .load_symbols(Some("deleted/descendant.rs"), Some("descendant"), 10)?
14781 .is_empty(),
14782 &true,
14783 "deleted descendant symbols are invalidated",
14784 )?;
14785 require_eq(
14786 &store
14787 .load_symbols(Some("changed.rs"), Some("after"), 10)?
14788 .len(),
14789 &1,
14790 "changed symbols are published",
14791 )?;
14792 require_eq(
14793 &store
14794 .load_symbols(Some("changed.rs"), Some("before"), 10)?
14795 .is_empty(),
14796 &true,
14797 "replaced symbols are invalidated",
14798 )?;
14799 let reserved = store
14800 .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
14801 .ok_or_else(|| io::Error::other("reserved metadata node missing"))?;
14802 require_eq(
14803 &reserved.purpose.purpose.as_deref(),
14804 &Some(reviewed_reserved_purpose),
14805 "stale reviewed built-in purpose text",
14806 )?;
14807 require_eq(
14808 &reserved.purpose.status,
14809 &PurposeStatus::Approved,
14810 "reviewed built-in purpose state",
14811 )?;
14812 require_eq(
14813 &old_reader
14814 .index_publication()?
14815 .as_ref()
14816 .map(|state| state.generation),
14817 &Some(before.generation),
14818 "old reader remains on the complete prior generation",
14819 )?;
14820 require_eq(
14821 &old_reader
14822 .load_file_text("changed.rs")?
14823 .map(|text| text.content),
14824 &Some(old_text.content),
14825 "old reader remains on prior source text",
14826 )?;
14827 let new_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
14828 require_eq(
14829 &new_reader
14830 .index_publication()?
14831 .as_ref()
14832 .map(|state| state.generation),
14833 &Some(after.generation),
14834 "new reader sees the complete replacement generation",
14835 )?;
14836 require_eq(
14837 &new_reader
14838 .load_file_text("changed.rs")?
14839 .map(|text| text.content),
14840 &Some("pub fn after() {}\n".to_string()),
14841 "new reader sees replacement source text",
14842 )?;
14843 new_reader.finish_index_read_snapshot()?;
14844 old_reader.finish_index_read_snapshot()?;
14845 Ok(())
14846 }
14847
14848 #[test]
14849 fn one_sided_notify_rename_events_require_full_verification() -> Result<(), Box<dyn Error>> {
14850 let temp = tempfile::tempdir()?;
14851 let old_path = temp.path().join("old.rs");
14852 let new_path = temp.path().join("new.rs");
14853 for (mode, path) in [
14854 (notify::event::RenameMode::From, old_path),
14855 (notify::event::RenameMode::To, new_path),
14856 ] {
14857 let event =
14858 Event::new(EventKind::Modify(notify::event::ModifyKind::Name(mode))).add_path(path);
14859 let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
14860 require_eq(
14861 &changes.requires_full_scan,
14862 &true,
14863 "one-sided rename full verification",
14864 )?;
14865 }
14866 Ok(())
14867 }
14868
14869 #[test]
14870 fn notify_rescan_and_ignored_policy_events_require_full_verification()
14871 -> Result<(), Box<dyn Error>> {
14872 let temp = tempfile::tempdir()?;
14873 fs::create_dir_all(temp.path().join(".projectatlas"))?;
14874 fs::write(temp.path().join(".gitignore"), ".projectatlas/\n")?;
14875 let config = temp.path().join(".projectatlas/config.toml");
14876 fs::write(&config, "[project]\nroot = \".\"\n")?;
14877 let config_event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
14878 notify::event::DataChange::Content,
14879 )))
14880 .add_path(config.clone());
14881 let config_changes =
14882 notify_event_changes(temp.path(), &ScanOptions::default(), &config_event);
14883 require_eq(
14884 &config_changes.requires_full_scan,
14885 &true,
14886 "ignored ProjectAtlas config full verification",
14887 )?;
14888 require_eq(
14889 &config_changes.paths.contains(&config),
14890 &true,
14891 "ignored ProjectAtlas config event path",
14892 )?;
14893
14894 let rescan_event = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
14895 let rescan_changes =
14896 notify_event_changes(temp.path(), &ScanOptions::default(), &rescan_event);
14897 require_eq(
14898 &rescan_changes.requires_full_scan,
14899 &true,
14900 "backend rescan flag full verification",
14901 )?;
14902 Ok(())
14903 }
14904
14905 #[test]
14906 fn nonindexed_document_target_transitions_refresh_the_inbound_closure()
14907 -> Result<(), Box<dyn Error>> {
14908 let temp = tempfile::tempdir()?;
14909 let docs = temp.path().join("docs");
14910 fs::create_dir_all(&docs)?;
14911 fs::write(temp.path().join(".gitignore"), "!docs/target.md\n")?;
14912 fs::write(docs.join("guide.md"), "# Guide\n\n[target](target.md)\n")?;
14913 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
14914 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
14915 let mut store = open_atlas_store_for_project(
14916 &temp.path().join(".projectatlas/projectatlas.db"),
14917 &plan.root,
14918 )?;
14919 refresh_index(&mut store, &plan, &symbol_options)?;
14920 let documents = GraphRelationKind::Extended(ExtendedRelationKind::Documents);
14921 let reason = |store: &AtlasStore| -> Result<_, Box<dyn Error>> {
14922 let page = store.repository_graph_relation_rows(
14923 RepositoryGraphRelationQuery::Family {
14924 relation: documents,
14925 },
14926 8,
14927 None,
14928 )?;
14929 require_eq(&page.rows.len(), &1, "document relation count")?;
14930 Ok(page.rows[0].document_unresolved_reason)
14931 };
14932 require_eq(
14933 &reason(&store)?,
14934 &Some(DocumentTargetUnresolvedReason::Missing),
14935 "initial missing document target",
14936 )?;
14937
14938 let outside = tempfile::tempdir()?;
14939 let outside_target = outside.path().join("target.md");
14940 fs::write(&outside_target, "outside\n")?;
14941 let target = docs.join("target.md");
14942 if !create_document_target_symlink(&outside_target, &target)? {
14943 return Ok(());
14944 }
14945 let mut changes = WatchChangeSet::default();
14946 changes.document_paths.insert(target.clone());
14947 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
14948 require_eq(
14949 &reason(&store)?,
14950 &Some(DocumentTargetUnresolvedReason::OutsideRoot),
14951 "missing-to-outside-symlink document target",
14952 )?;
14953
14954 fs::remove_file(&target)?;
14955 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
14956 require_eq(
14957 &reason(&store)?,
14958 &Some(DocumentTargetUnresolvedReason::Missing),
14959 "outside-symlink-to-missing document target",
14960 )?;
14961 Ok(())
14962 }
14963
14964 #[cfg(unix)]
14965 fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
14966 std::os::unix::fs::symlink(target, link)?;
14967 Ok(true)
14968 }
14969
14970 #[cfg(windows)]
14971 fn create_document_target_symlink(target: &Path, link: &Path) -> io::Result<bool> {
14972 match std::os::windows::fs::symlink_file(target, link) {
14973 Ok(()) => Ok(true),
14974 Err(source)
14975 if source.kind() == io::ErrorKind::PermissionDenied
14976 || source.raw_os_error() == Some(1314) =>
14977 {
14978 Ok(false)
14979 }
14980 Err(source) => Err(source),
14981 }
14982 }
14983
14984 #[test]
14985 fn directory_only_deletion_re_resolves_external_inbound_callers() -> Result<(), Box<dyn Error>>
14986 {
14987 let temp = tempfile::tempdir()?;
14988 let atlas_dir = temp.path().join(".projectatlas");
14989 let source_dir = temp.path().join("src");
14990 let removed_dir = source_dir.join("removed");
14991 fs::create_dir_all(&atlas_dir)?;
14992 fs::create_dir_all(&removed_dir)?;
14993 fs::write(
14994 source_dir.join("caller.rs"),
14995 "use crate::removed::target::target;\npub fn caller() { target(); }\n",
14996 )?;
14997 fs::write(removed_dir.join("target.rs"), "pub fn target() {}\n")?;
14998
14999 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
15000 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
15001 let mut store =
15002 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
15003 refresh_index(&mut store, &plan, &symbol_options)?;
15004 let before = store
15005 .index_publication()?
15006 .ok_or_else(|| io::Error::other("initial publication missing"))?;
15007 require_caller_resolution(
15008 &store,
15009 "src/caller.rs",
15010 |resolution| matches!(resolution, RelationResolution::Resolved { .. }),
15011 "initial caller resolution",
15012 )?;
15013
15014 fs::remove_dir_all(&removed_dir)?;
15015 let mut changes = WatchChangeSet::default();
15016 changes.paths.insert(removed_dir);
15017 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
15018
15019 let after = store
15020 .index_publication()?
15021 .ok_or_else(|| io::Error::other("replacement publication missing"))?;
15022 require_eq(
15023 &after.generation,
15024 &before
15025 .generation
15026 .checked_next()
15027 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
15028 "directory deletion generation",
15029 )?;
15030 require_eq(
15031 &store.load_node_by_path("src/removed/target.rs")?.is_none(),
15032 &true,
15033 "deleted descendant node",
15034 )?;
15035 require_eq(
15036 &store
15037 .load_symbols(Some("src/removed/target.rs"), Some("target"), 10)?
15038 .is_empty(),
15039 &true,
15040 "deleted descendant symbol",
15041 )?;
15042 require_caller_resolution(
15043 &store,
15044 "src/caller.rs",
15045 |resolution| matches!(resolution, RelationResolution::Unresolved { .. }),
15046 "caller resolution after directory deletion",
15047 )?;
15048 Ok(())
15049 }
15050
15051 fn require_caller_resolution(
15052 store: &AtlasStore,
15053 caller_path: &str,
15054 expected: impl FnOnce(&RelationResolution) -> bool,
15055 label: &str,
15056 ) -> Result<(), Box<dyn Error>> {
15057 let project = store
15058 .project_instance_id()?
15059 .ok_or_else(|| io::Error::other("project identity missing"))?;
15060 let caller_path = RepositoryNodePath::new(Path::new(caller_path))?;
15061 let caller_entities =
15062 store.repository_graph_entities_by_path(project, &caller_path, 100)?;
15063 let relations = store.repository_graph_relations(
15064 RepositoryGraphRelationQuery::Family {
15065 relation: GraphRelationKind::Legacy(RelationKind::Calls),
15066 },
15067 100,
15068 )?;
15069 let mut matching = relations.rows.iter().filter(|relation| {
15070 caller_entities
15071 .rows
15072 .iter()
15073 .any(|entity| entity.key() == relation.source())
15074 });
15075 let relation = matching
15076 .next()
15077 .ok_or_else(|| io::Error::other(format!("{label}: caller relation missing")))?;
15078 if matching.next().is_some() {
15079 return Err(io::Error::other(format!("{label}: multiple caller relations")).into());
15080 }
15081 if !expected(relation.resolution()) {
15082 return Err(io::Error::other(format!(
15083 "{label}: unexpected resolution {:?}",
15084 relation.resolution()
15085 ))
15086 .into());
15087 }
15088 Ok(())
15089 }
15090
15091 #[test]
15092 fn purpose_curator_handoff_applies_one_stale_safe_batch() -> Result<(), Box<dyn Error>> {
15093 let temp = tempfile::tempdir()?;
15094 let root = temp.path().join("repository");
15095 fs::create_dir(&root)?;
15096 let database = temp.path().join("projectatlas.db");
15097 let mut store = AtlasStore::open_for_project(&database, &root)?;
15098 store.replace_scan(&[
15099 Node {
15100 path: "src/main.rs".to_string(),
15101 kind: NodeKind::File,
15102 parent_path: Some("src".to_string()),
15103 extension: Some(".rs".to_string()),
15104 language: Some("rust".to_string()),
15105 size_bytes: Some(12),
15106 mtime_ns: Some(10),
15107 content_hash: Some("hash-main".to_string()),
15108 },
15109 Node {
15110 path: "src/detail.rs".to_string(),
15111 kind: NodeKind::File,
15112 parent_path: Some("src".to_string()),
15113 extension: Some(".rs".to_string()),
15114 language: Some("rust".to_string()),
15115 size_bytes: Some(12),
15116 mtime_ns: Some(10),
15117 content_hash: Some("hash-detail".to_string()),
15118 },
15119 ])?;
15120 store.set_suggested_purpose("src/detail.rs", "Generated detail suggestion")?;
15121 let task = "runtime-purpose-curator";
15122 let page = purpose_curation_page(
15123 &store,
15124 &HealthQuery {
15125 start_index: 0,
15126 limit: 20,
15127 category: None,
15128 severity: Some(Severity::Warning),
15129 path_prefix: None,
15130 summary_only: false,
15131 scope: HealthScope::all(),
15132 },
15133 task,
15134 )?;
15135 require_eq(&page.actionable, &true, "actionable queue")?;
15136 require_eq(&page.items.len(), &2, "queue item count")?;
15137 require_eq(&page.task, &task.to_string(), "queue task")?;
15138 require_eq(
15139 &page
15140 .items
15141 .iter()
15142 .all(|item| item.classification == Some(ContentClassification::Opaque)),
15143 &true,
15144 "queue file classifications",
15145 )?;
15146 let requests = page
15147 .items
15148 .iter()
15149 .map(|item| PurposeReviewRequest {
15150 path: item.path.clone(),
15151 purpose: Some(format!("Reviewed purpose for {}", item.path)),
15152 confirm_existing: false,
15153 task: Some(page.task.clone()),
15154 work_key: Some(item.work_key.clone()),
15155 state_token: Some(item.state_token.clone()),
15156 })
15157 .collect::<Vec<_>>();
15158 let handoff = purpose_curator_handoff(page);
15159 require_eq(
15160 &handoff.execution_owner,
15161 &"agent_host",
15162 "host-owned execution",
15163 )?;
15164 require_eq(
15165 &handoff.server_started_curator,
15166 &false,
15167 "no server-started curator",
15168 )?;
15169 require_eq(
15170 &handoff.recommended_subagent_reasoning,
15171 &PURPOSE_CURATOR_RECOMMENDED_REASONING,
15172 "lowest reliable host-supported reasoning",
15173 )?;
15174 require_eq(
15175 &handoff.instructions.first().is_some_and(|instruction| {
15176 instruction.contains("lowest reliable reasoning and cost tier the host supports")
15177 }),
15178 &true,
15179 "purpose handoff omitted the reliable reasoning and cost qualification",
15180 )?;
15181 require_eq(&handoff.main_agent_fallback, &true, "main-agent fallback")?;
15182
15183 let mixed = vec![
15184 requests[0].clone(),
15185 PurposeReviewRequest {
15186 path: requests[1].path.clone(),
15187 purpose: Some("Explicit correction must stay separate".to_string()),
15188 confirm_existing: false,
15189 task: None,
15190 work_key: None,
15191 state_token: None,
15192 },
15193 ];
15194 match review_purposes(&store, &mixed, true) {
15195 Err(CliError::InvalidInput(_)) => {}
15196 Err(error) => {
15197 return Err(io::Error::other(format!(
15198 "mixed purpose batch returned the wrong error: {error}"
15199 ))
15200 .into());
15201 }
15202 Ok(_) => return Err(io::Error::other("mixed purpose batch was accepted").into()),
15203 }
15204 let mut partial = requests[1].clone();
15205 partial.state_token = None;
15206 match review_purposes(&store, &[requests[0].clone(), partial], true) {
15207 Err(CliError::InvalidInput(_)) => {}
15208 Err(error) => {
15209 return Err(io::Error::other(format!(
15210 "partial conditional batch returned the wrong error: {error}"
15211 ))
15212 .into());
15213 }
15214 Ok(_) => {
15215 return Err(io::Error::other("partial conditional batch was accepted").into());
15216 }
15217 }
15218 drop(store);
15219 store = AtlasStore::open_for_project(&database, &root)?;
15220 let unchanged =
15221 store.load_nodes_by_paths(&["src/main.rs".to_string(), "src/detail.rs".to_string()])?;
15222 require_eq(
15223 &unchanged.iter().all(|node| !node.purpose.agent_reviewed()),
15224 &true,
15225 "rejected batch left every purpose unapproved after reopen",
15226 )?;
15227
15228 let applied = review_purposes(&store, &requests, true)?;
15229 require_eq(&applied.changed, &2, "conditional batch changed count")?;
15230 require_eq(&applied.conflicts, &0, "conditional batch conflicts")?;
15231 require_eq(
15232 &applied
15233 .items
15234 .iter()
15235 .all(|item| item.classification == Some(ContentClassification::Opaque)),
15236 &true,
15237 "purpose review classifications",
15238 )?;
15239 require_eq(
15240 &applied
15241 .items
15242 .iter()
15243 .all(|item| item.action == PurposeReviewAction::Review),
15244 &true,
15245 "conditional batch actions",
15246 )?;
15247
15248 let repeated = review_purposes(&store, &requests, true)?;
15249 require_eq(&repeated.changed, &0, "accepted repeat changed count")?;
15250 require_eq(&repeated.conflicts, &2, "accepted repeat conflicts")?;
15251 require_eq(
15252 &repeated
15253 .items
15254 .iter()
15255 .all(|item| item.action == PurposeReviewAction::Accepted),
15256 &true,
15257 "accepted repeat actions",
15258 )?;
15259 let empty = purpose_curation_page(
15260 &store,
15261 &HealthQuery {
15262 start_index: 0,
15263 limit: 20,
15264 category: None,
15265 severity: Some(Severity::Warning),
15266 path_prefix: None,
15267 summary_only: false,
15268 scope: HealthScope::all(),
15269 },
15270 task,
15271 )?;
15272 require_eq(&empty.actionable, &false, "accepted queue is quiet")?;
15273
15274 let correction = review_purposes(
15275 &store,
15276 &[PurposeReviewRequest {
15277 path: "src/main.rs".to_string(),
15278 purpose: Some("Explicit corrected purpose".to_string()),
15279 confirm_existing: false,
15280 task: None,
15281 work_key: None,
15282 state_token: None,
15283 }],
15284 true,
15285 )?;
15286 require_eq(
15287 &correction.items[0].action,
15288 &PurposeReviewAction::Review,
15289 "explicit correction action",
15290 )?;
15291 let corrected = store
15292 .load_node_by_path("src/main.rs")?
15293 .ok_or_else(|| io::Error::other("corrected runtime path disappeared"))?;
15294 require_eq(
15295 &corrected.purpose.purpose.as_deref(),
15296 &Some("Explicit corrected purpose"),
15297 "explicit correction value",
15298 )?;
15299 Ok(())
15300 }
15301
15302 #[test]
15303 fn purpose_review_admission_bounds_input_and_prevents_partial_apply()
15304 -> Result<(), Box<dyn Error>> {
15305 let temp = tempfile::tempdir()?;
15306 let root = temp.path().join("repository");
15307 fs::create_dir(&root)?;
15308 let database = temp.path().join("projectatlas.db");
15309 let mut store = AtlasStore::open_for_project(&database, &root)?;
15310 store.replace_scan(&[
15311 Node {
15312 path: "src/first.rs".to_string(),
15313 kind: NodeKind::File,
15314 parent_path: Some("src".to_string()),
15315 extension: Some(".rs".to_string()),
15316 language: Some("rust".to_string()),
15317 size_bytes: Some(12),
15318 mtime_ns: Some(10),
15319 content_hash: Some("hash-first".to_string()),
15320 },
15321 Node {
15322 path: "src/second.rs".to_string(),
15323 kind: NodeKind::File,
15324 parent_path: Some("src".to_string()),
15325 extension: Some(".rs".to_string()),
15326 language: Some("rust".to_string()),
15327 size_bytes: Some(12),
15328 mtime_ns: Some(10),
15329 content_hash: Some("hash-second".to_string()),
15330 },
15331 ])?;
15332 store.set_suggested_purpose("src/first.rs", "Generated first purpose")?;
15333 store.set_purpose(
15334 "src/second.rs",
15335 &"x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1),
15336 PurposeSource::Imported,
15337 )?;
15338
15339 let valid_first = PurposeReviewRequest {
15340 path: "src/first.rs".to_string(),
15341 purpose: Some("Reviewed café λ purpose".to_string()),
15342 confirm_existing: false,
15343 task: None,
15344 work_key: None,
15345 state_token: None,
15346 };
15347 let oversized_report = PurposeReviewRequest {
15348 path: "src/second.rs".to_string(),
15349 purpose: None,
15350 confirm_existing: true,
15351 task: None,
15352 work_key: None,
15353 state_token: None,
15354 };
15355 let Err(error) = review_purposes(&store, &[valid_first.clone(), oversized_report], true)
15356 else {
15357 return Err(io::Error::other(
15358 "oversized retained report field was accepted before apply",
15359 )
15360 .into());
15361 };
15362 require_eq(
15363 &error
15364 .to_string()
15365 .contains("purpose review report field purpose"),
15366 &true,
15367 "oversized report field error",
15368 )?;
15369 let unchanged = store
15370 .load_node_by_path("src/first.rs")?
15371 .ok_or_else(|| io::Error::other("first review fixture disappeared"))?;
15372 require_eq(
15373 &unchanged.purpose.agent_reviewed(),
15374 &false,
15375 "report admission failure prevented partial apply",
15376 )?;
15377
15378 let oversized_field = PurposeReviewRequest {
15379 purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1)),
15380 ..valid_first.clone()
15381 };
15382 let Err(error) = review_purposes(&store, &[oversized_field], true) else {
15383 return Err(io::Error::other("oversized request field passed admission").into());
15384 };
15385 require_eq(
15386 &error.to_string().contains("field purpose"),
15387 &true,
15388 "oversized input field error",
15389 )?;
15390
15391 let too_many = vec![valid_first.clone(); MAX_PURPOSE_CURATION_BATCH_ROWS + 1];
15392 let Err(error) = review_purposes(&store, &too_many, true) else {
15393 return Err(io::Error::other("oversized request count passed admission").into());
15394 };
15395 require_eq(
15396 &error.to_string().contains("maximum is 200"),
15397 &true,
15398 "oversized item count error",
15399 )?;
15400
15401 let aggregate = (0..9)
15402 .map(|index| PurposeReviewRequest {
15403 path: format!("src/{index}.rs"),
15404 purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES)),
15405 confirm_existing: false,
15406 task: None,
15407 work_key: None,
15408 state_token: None,
15409 })
15410 .collect::<Vec<_>>();
15411 let Err(error) = review_purposes(&store, &aggregate, false) else {
15412 return Err(io::Error::other("oversized aggregate request passed admission").into());
15413 };
15414 require_eq(
15415 &error.to_string().contains("aggregate string bytes"),
15416 &true,
15417 "oversized aggregate input error",
15418 )?;
15419
15420 let preview = review_purposes(&store, &[valid_first], false)?;
15421 require_eq(
15422 &preview.items[0].purpose,
15423 &"Reviewed café λ purpose".to_string(),
15424 "UTF-8 purpose compatibility",
15425 )?;
15426 require_eq(
15427 &render_purpose_review_report(&preview).contains("Reviewed café λ purpose"),
15428 &true,
15429 "UTF-8 TOON compatibility",
15430 )?;
15431 Ok(())
15432 }
15433
15434 #[test]
15435 fn indexed_navigation_read_rejects_stale_or_oversized_source_before_allocation()
15436 -> Result<(), Box<dyn Error>> {
15437 let temp = tempfile::tempdir()?;
15438 let root = temp.path().join("repository");
15439 let source_dir = root.join("src");
15440 fs::create_dir_all(&source_dir)?;
15441 let source = source_dir.join("large.rs");
15442 fs::File::create(&source)?.set_len(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)?;
15443 let database = temp.path().join("projectatlas.db");
15444 let mut store = AtlasStore::open_for_project(&database, &root)?;
15445 store.replace_scan(&[Node {
15446 path: "src/large.rs".to_string(),
15447 kind: NodeKind::File,
15448 parent_path: Some("src".to_string()),
15449 extension: Some(".rs".to_string()),
15450 language: Some("rust".to_string()),
15451 size_bytes: Some(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1),
15452 mtime_ns: Some(10),
15453 content_hash: Some("unused-oversized-hash".to_string()),
15454 }])?;
15455
15456 let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
15457 return Err(
15458 io::Error::other("oversized indexed source was allocated and accepted").into(),
15459 );
15460 };
15461 let CliError::VerificationIncomplete(details) = error else {
15462 return Err(io::Error::other("oversized source returned the wrong error type").into());
15463 };
15464 require_eq(
15465 &details.reason,
15466 &IndexVerificationReason::SourceTooLarge,
15467 "oversized source reason",
15468 )?;
15469
15470 fs::File::create(&source)?.set_len(1)?;
15471 let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
15472 return Err(io::Error::other("changed source size did not require refresh").into());
15473 };
15474 let CliError::RefreshRequired(details) = error else {
15475 return Err(
15476 io::Error::other("changed source size returned the wrong error type").into(),
15477 );
15478 };
15479 require_eq(
15480 &details.reason,
15481 &IndexRefreshReason::SourceChanged,
15482 "changed source size reason",
15483 )?;
15484 Ok(())
15485 }
15486
15487 #[cfg(windows)]
15488 #[test]
15489 fn extended_windows_watch_roots_keep_deleted_paths_in_scope() -> Result<(), Box<dyn Error>> {
15490 let root = Path::new(r"\\?\C:\repo");
15491 let deleted = Path::new(r"C:\repo\src\deleted.rs");
15492 require_eq(
15493 &normalized_deleted_path(root, deleted)?,
15494 &Some("src/deleted.rs".to_string()),
15495 "extended root deleted path",
15496 )?;
15497 Ok(())
15498 }
15499
15500 #[cfg(windows)]
15501 #[test]
15502 fn notify_events_normalize_unicode_paths_against_extended_windows_roots()
15503 -> Result<(), Box<dyn Error>> {
15504 let temp = tempfile::tempdir()?;
15505 let source_dir = temp.path().join("src");
15506 fs::create_dir(&source_dir)?;
15507 let deleted = source_dir.join("Über.rs");
15508 fs::write(&deleted, "pub fn before() {}\n")?;
15509 fs::remove_file(&deleted)?;
15510
15511 let root_text = temp
15512 .path()
15513 .to_str()
15514 .ok_or_else(|| io::Error::other("temporary path is not UTF-8"))?;
15515 let extended_root = if root_text.starts_with(r"\\?\") {
15516 temp.path().to_path_buf()
15517 } else {
15518 PathBuf::from(format!(r"\\?\{root_text}"))
15519 };
15520 let event = Event::new(EventKind::Remove(notify::event::RemoveKind::File))
15521 .add_path(deleted.clone());
15522 let changes = notify_event_changes(&extended_root, &ScanOptions::default(), &event);
15523
15524 require_eq(
15525 &changes.paths.contains(&deleted),
15526 &true,
15527 "native Unicode watcher path",
15528 )?;
15529 require_eq(
15530 &changes.requires_full_scan,
15531 &false,
15532 "source removal full-scan policy",
15533 )?;
15534 require_eq(
15535 &normalized_deleted_path(&extended_root, &deleted)?,
15536 &Some("src/Über.rs".to_string()),
15537 "normalized Unicode deleted path",
15538 )?;
15539 Ok(())
15540 }
15541
15542 #[cfg(unix)]
15543 #[test]
15544 fn notify_events_preserve_native_backslash_paths_on_unix() -> Result<(), Box<dyn Error>> {
15545 let temp = tempfile::tempdir()?;
15546 let source = temp.path().join(r"src\generated.rs");
15547 fs::write(&source, "pub fn generated() {}\n")?;
15548 let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
15549 notify::event::DataChange::Content,
15550 )))
15551 .add_path(source.clone());
15552
15553 let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
15554
15555 require_eq(
15556 &changes.paths,
15557 &HashSet::from([source]),
15558 "native Unix watcher paths",
15559 )?;
15560 Ok(())
15561 }
15562
15563 fn runtime_pdf_fixture() -> Vec<u8> {
15564 let objects = [
15565 b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".as_slice(),
15566 b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".as_slice(),
15567 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(),
15568 b"4 0 obj\n<< /Length 42 >>\nstream\nBT /F1 12 Tf 72 720 Td (Runtime PDF) Tj ET\nendstream\nendobj\n".as_slice(),
15569 b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".as_slice(),
15570 ];
15571 let mut pdf = b"%PDF-1.4\n".to_vec();
15572 let mut offsets = Vec::new();
15573 for object in objects {
15574 offsets.push(pdf.len());
15575 pdf.extend_from_slice(object);
15576 }
15577 let xref = pdf.len();
15578 pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
15579 pdf.extend_from_slice(b"0000000000 65535 f \n");
15580 for offset in offsets {
15581 pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
15582 }
15583 pdf.extend_from_slice(
15584 format!(
15585 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
15586 objects.len() + 1
15587 )
15588 .as_bytes(),
15589 );
15590 pdf
15591 }
15592
15593 #[test]
15594 fn document_input_does_not_consume_retained_text_capacity() -> Result<(), Box<dyn Error>> {
15595 let temp = tempfile::tempdir()?;
15596 let pdf = runtime_pdf_fixture();
15597 let mut nodes = Vec::new();
15598 for (path, language, bytes) in [
15599 ("seed.txt", "text", b"seed".as_slice()),
15600 ("guide.pdf", "pdf", pdf.as_slice()),
15601 ] {
15602 fs::write(temp.path().join(path), bytes)?;
15603 nodes.push(Node {
15604 path: path.to_owned(),
15605 kind: NodeKind::File,
15606 parent_path: None,
15607 extension: None,
15608 language: Some(language.to_owned()),
15609 size_bytes: Some(bytes.len() as u64),
15610 mtime_ns: Some(1),
15611 content_hash: Some(blake3::hash(bytes).to_hex().to_string()),
15612 });
15613 }
15614 let control = standalone_index_work_control();
15615 for known_size in [true, false] {
15616 nodes[1].size_bytes = known_size.then_some(pdf.len() as u64);
15617 let rows = indexed_file_texts_for_nodes_with_limit(
15618 temp.path(),
15619 &nodes,
15620 TextIndexOptions::new(64),
15621 15,
15622 &control,
15623 )?;
15624 if rows.len() != 2
15625 || rows[1].text.as_ref().map(|text| text.content.as_str()) != Some("Runtime PDF")
15626 {
15627 return Err(
15628 io::Error::other("document input consumed retained-text capacity").into(),
15629 );
15630 }
15631 if !matches!(
15632 indexed_file_texts_for_nodes_with_limit(
15633 temp.path(),
15634 &nodes,
15635 TextIndexOptions::new(64),
15636 14,
15637 &control,
15638 ),
15639 Err(CliError::IndexWork(
15640 IndexWorkFailure::ResourceLimitExceeded {
15641 stage: IndexWorkStage::TextIndex,
15642 resource: IndexWorkResource::TextBytes,
15643 ..
15644 }
15645 ))
15646 ) {
15647 return Err(io::Error::other(
15648 "extracted document text exceeded retained-text capacity",
15649 )
15650 .into());
15651 }
15652 }
15653 Ok(())
15654 }
15655
15656 #[test]
15657 fn document_text_staging_preserves_typed_resource_refusal() -> Result<(), Box<dyn Error>> {
15658 let temp = tempfile::tempdir()?;
15659 let maximum = projectatlas_symbols::MAX_DOCUMENT_COMPRESSED_BYTES;
15660 let mut bytes = vec![b' '; maximum + 1];
15661 bytes[..5].copy_from_slice(b"%PDF-");
15662 fs::write(temp.path().join("oversized.pdf"), &bytes)?;
15663 let node = Node {
15664 path: "oversized.pdf".to_owned(),
15665 kind: NodeKind::File,
15666 parent_path: None,
15667 extension: Some(".pdf".to_owned()),
15668 language: Some("pdf".to_owned()),
15669 size_bytes: Some(bytes.len() as u64),
15670 mtime_ns: Some(1),
15671 content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
15672 };
15673 let result = indexed_file_texts_for_nodes(
15674 temp.path(),
15675 &[node],
15676 TextIndexOptions::new(MAX_SYMBOL_FILE_BYTES),
15677 );
15678 if matches!(
15679 result,
15680 Err(CliError::IndexWork(
15681 IndexWorkFailure::ResourceLimitExceeded {
15682 stage: IndexWorkStage::TextIndex,
15683 resource: IndexWorkResource::SourceBytes,
15684 ..
15685 }
15686 ))
15687 ) {
15688 Ok(())
15689 } else {
15690 Err(io::Error::other(format!(
15691 "document resource refusal lost its type: {result:?}"
15692 ))
15693 .into())
15694 }
15695 }
15696
15697 #[test]
15698 fn document_bytes_reach_text_and_symbol_publication_paths() -> Result<(), Box<dyn Error>> {
15699 let temp = tempfile::tempdir()?;
15700 let path = temp.path().join("guide.pdf");
15701 let bytes = runtime_pdf_fixture();
15702 fs::write(&path, &bytes)?;
15703 let node = Node {
15704 path: "guide.pdf".to_owned(),
15705 kind: NodeKind::File,
15706 parent_path: None,
15707 extension: Some(".pdf".to_owned()),
15708 language: Some("pdf".to_owned()),
15709 size_bytes: Some(bytes.len() as u64),
15710 mtime_ns: Some(1),
15711 content_hash: Some(blake3::hash(&bytes).to_hex().to_string()),
15712 };
15713 let rows = indexed_file_texts_for_nodes(
15714 temp.path(),
15715 std::slice::from_ref(&node),
15716 TextIndexOptions::new(projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64),
15717 )?;
15718 require_eq(&rows.len(), &1, "document text row count")?;
15719 require_eq(
15720 &rows[0].text.as_ref().map(|text| text.content.as_str()),
15721 &Some("Runtime PDF"),
15722 "document text content",
15723 )?;
15724 let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
15725 &SymbolParseJob {
15726 path: node.path,
15727 native_path: path,
15728 expected_content_hash: node.content_hash.unwrap_or_default(),
15729 language: node.language,
15730 fallback_summary: None,
15731 purpose_needs_suggestion: false,
15732 },
15733 &SymbolBuildOptions::new(
15734 projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
15735 Some(1),
15736 None,
15737 ),
15738 Instant::now(),
15739 ) else {
15740 return Err(io::Error::other("document symbol job did not parse").into());
15741 };
15742 require_eq(&parsed.graph.symbols.len(), &1, "document symbol count")?;
15743 require_eq(
15744 &parsed.graph.symbols[0]
15745 .signature
15746 .contains("pdf:page=1;text-span="),
15747 &true,
15748 "document locator persisted in graph symbol",
15749 )?;
15750 Ok(())
15751 }
15752
15753 #[test]
15754 fn document_coverage_marker_does_not_use_publication_symbol_budget()
15755 -> Result<(), Box<dyn Error>> {
15756 let facts = projectatlas_symbols::DocumentFacts {
15757 format: projectatlas_symbols::DocumentFormat::Docx,
15758 text: "Hello".to_owned(),
15759 facts: vec![projectatlas_symbols::DocumentFact {
15760 text: "Hello".to_owned(),
15761 locator: projectatlas_symbols::DocumentLocator::Docx {
15762 part: "word/document.xml".to_owned(),
15763 paragraph: 1,
15764 run: 1,
15765 text_start: 0,
15766 text_end: 5,
15767 },
15768 line_start: 1,
15769 line_end: 1,
15770 }],
15771 symbols: Vec::new(),
15772 completeness: projectatlas_symbols::DocumentCompleteness::Partial {
15773 gaps: vec![projectatlas_symbols::DocumentCoverageGap::UnexaminedStory],
15774 },
15775 provenance: projectatlas_symbols::DocumentParserProvenance::QuickXml,
15776 };
15777 let mut parsed = SymbolParseSuccess {
15778 path: "guide.docx".to_owned(),
15779 graph: facts.symbol_graph("guide.docx", None),
15780 markdown_facts: None,
15781 source_semantics: None,
15782 source_parser: ParserKind::Structural,
15783 summary: "Hello".to_owned(),
15784 summary_is_structural: true,
15785 purpose_suggestion: None,
15786 };
15787 let with_marker = symbol_parse_output_bytes(&parsed);
15788 require_eq(
15789 &graph_projection::navigable_symbol_count(&parsed.graph),
15790 &1,
15791 "only the real document block uses a symbol row",
15792 )?;
15793 parsed.graph.symbols.pop();
15794 require_eq(
15795 &with_marker,
15796 &symbol_parse_output_bytes(&parsed),
15797 "coverage marker used publication output bytes",
15798 )?;
15799 Ok(())
15800 }
15801
15802 #[test]
15803 fn empty_document_refresh_discards_previous_summary() -> Result<(), Box<dyn Error>> {
15804 let bytes = runtime_pdf_fixture();
15805 let mut job = SymbolParseJob {
15806 path: "guide.pdf".to_owned(),
15807 native_path: PathBuf::from("guide.pdf"),
15808 expected_content_hash: String::new(),
15809 language: Some("pdf".to_owned()),
15810 fallback_summary: None,
15811 purpose_needs_suggestion: true,
15812 };
15813 let options = SymbolBuildOptions::new(
15814 projectatlas_symbols::MAX_DOCUMENT_OUTPUT_BYTES as u64,
15815 Some(1),
15816 None,
15817 );
15818 let control = standalone_index_work_control();
15819 let SymbolParseOutcome::Parsed(previous) =
15820 parse_document_symbol_job(&job, &bytes, &options, &control)
15821 else {
15822 return Err(io::Error::other("document did not parse").into());
15823 };
15824 require_eq(&previous.graph.symbols.len(), &1, "previous document block")?;
15825 require_eq(
15826 &previous.summary.contains("Runtime PDF"),
15827 &true,
15828 "literal document summary",
15829 )?;
15830 require_eq(
15831 &previous
15832 .purpose_suggestion
15833 .as_deref()
15834 .unwrap_or_default()
15835 .contains("Runtime PDF"),
15836 &true,
15837 "literal document purpose suggestion",
15838 )?;
15839 job.fallback_summary = Some(previous.summary);
15840 let empty = String::from_utf8(bytes)?.replace("Runtime PDF", " ");
15841 let SymbolParseOutcome::Parsed(current) =
15842 parse_document_symbol_job(&job, empty.as_bytes(), &options, &control)
15843 else {
15844 return Err(io::Error::other("empty document did not parse").into());
15845 };
15846 require_eq(¤t.graph.symbols.len(), &0, "empty document blocks")?;
15847 require_eq(
15848 ¤t.summary.contains("Runtime PDF"),
15849 &false,
15850 "removed document text",
15851 )?;
15852 require_eq(
15853 ¤t.summary.contains("document-block-1"),
15854 &false,
15855 "summary must discard removed document blocks",
15856 )?;
15857 require_eq(
15858 ¤t
15859 .purpose_suggestion
15860 .as_deref()
15861 .unwrap_or_default()
15862 .contains("document-block-1"),
15863 &false,
15864 "suggested purpose must discard removed document blocks",
15865 )?;
15866 Ok(())
15867 }
15868
15869 #[cfg(unix)]
15870 #[test]
15871 fn notify_events_preserve_non_utf8_worktree_root_identity() -> Result<(), Box<dyn Error>> {
15872 use std::ffi::OsString;
15873 use std::os::unix::ffi::OsStringExt;
15874
15875 let temp = tempfile::tempdir()?;
15876 let root = temp
15877 .path()
15878 .join(OsString::from_vec(b"worktree-root-\xff".to_vec()));
15879 let source = root.join("src").join("generated.rs");
15880 fs::create_dir_all(
15881 source
15882 .parent()
15883 .ok_or_else(|| io::Error::other("watch source has no parent"))?,
15884 )?;
15885 fs::write(&source, "pub fn generated() {}\n")?;
15886 let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
15887 notify::event::DataChange::Content,
15888 )))
15889 .add_path(source.clone());
15890
15891 let changes = notify_event_changes(&root, &ScanOptions::default(), &event);
15892
15893 require_eq(
15894 &changes.paths,
15895 &HashSet::from([source.clone()]),
15896 "non-UTF-8 worktree-root watcher path",
15897 )?;
15898 require_eq(
15899 &normalized_deleted_path(&root, &source)?,
15900 &Some("src/generated.rs".to_string()),
15901 "non-UTF-8 worktree-root relative watcher path",
15902 )?;
15903 Ok(())
15904 }
15905
15906 #[cfg(unix)]
15908 fn require_condition(condition: bool, label: &str) -> Result<(), Box<dyn Error>> {
15909 if condition {
15910 Ok(())
15911 } else {
15912 Err(io::Error::other(label).into())
15913 }
15914 }
15915
15916 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
15918 where
15919 T: Debug + PartialEq,
15920 {
15921 if actual == expected {
15922 Ok(())
15923 } else {
15924 Err(io::Error::other(format!(
15925 "{label} mismatch: expected {expected:?}, got {actual:?}"
15926 ))
15927 .into())
15928 }
15929 }
15930}