1mod graph_projection;
5mod module_resolution;
6#[cfg(feature = "optional-parser-supervisor")]
7mod optional_parser_runtime;
8mod source_observation;
9
10pub(crate) use source_observation::{
11 SourceObservationRegistry, VerifiedReadOutcome, VerifiedReadStamp,
12};
13
14use crate::atlas_map::{
15 self, init_project_with_config, load_atlas_config, load_atlas_config_for_root,
16 load_atlas_config_from_text,
17};
18use crate::structural::{
19 is_scanner_fallback_summary, is_structural_summary_candidate, structural_summary_for_path,
20};
21use crate::{
22 CliError, OutputFormat, WATCH_MODE_NOTIFY, WATCH_MODE_ONCE, WATCH_MODE_POLLING, truthy_env,
23};
24use blake3::Hasher;
25use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
26#[cfg(feature = "optional-parser-supervisor")]
27use projectatlas_cli::optional_parser_lifecycle::{
28 OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH, OptionalParserPackLifecycle,
29 OptionalParserPackLifecycleReport, OptionalParserPackProjectSelection,
30};
31use projectatlas_core::health::{
32 CATEGORY_DUPLICATE_PURPOSE, CATEGORY_MISSING_PURPOSE, CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED,
33 CATEGORY_REPEATED_TEMPORARY_FOLDER, CATEGORY_STALE_PURPOSE, CATEGORY_SUGGESTED_PURPOSE_REVIEW,
34 Severity,
35};
36use projectatlas_core::language::{
37 ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION, LANGUAGE_CAPABILITY_REGISTRY_VERSION,
38 LanguageRegistryReport, SymbolParserOwner, accepted_language_capability_digest,
39 language_capability, language_registry_digest, language_registry_report,
40};
41#[cfg(all(test, feature = "optional-parser-supervisor"))]
42use projectatlas_core::optional_parser_pack::OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION;
43use projectatlas_core::outline::estimate_tokens;
44use projectatlas_core::relation_capabilities::{
45 RelationFamilyInventoryReport, relation_family_inventory_report,
46};
47use projectatlas_core::symbols::{
48 ParserKind, RelationKind, SourceParseMetadata, SymbolGraph, SymbolKind,
49};
50use projectatlas_core::telemetry::{
51 TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
52 TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
53 UsageInstanceId, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
54};
55use projectatlas_core::toon::{encode_agent_payload, render_ranked_node_rows};
56use projectatlas_core::{
57 IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkResource,
58 IndexWorkStage, Node, NodeKind, Overview, PurposeSource, PurposeStatus,
59 normalize_native_path_display, normalize_native_path_display_str, normalize_repo_path,
60 purpose_review_signal, repo_path_to_native, validated_repo_file_key, validated_repo_node_key,
61};
62use projectatlas_db::{
63 AtlasStore, DatabasePublicationContractState, DatabasePublicationReport,
64 DatabaseSchemaCompatibility, DatabaseSettingsReport, HealthFindingsPage, HealthQuery,
65 HealthScope, IndexPublication, IndexPublicationGuard, IndexPublicationState, IndexedFileText,
66 MAX_PURPOSE_CURATION_BATCH_ROWS, PurposeConditionalApplyRequest, PurposeConditionalApplyState,
67 TelemetryRetentionState, database_settings_report, read_project_root_read_only,
68 validate_database_location,
69};
70use projectatlas_fs::{
71 FsError, RootScanPolicy, ScanLimits, ScanOptions, gitignore_excludes_path,
72 scan_path_with_policy_controlled, scan_repo, scan_repo_controlled,
73 scan_repo_controlled_with_work,
74};
75use projectatlas_service::{
76 CoverageDiscoveryReport, FederatedInputWork, FederatedStore, FilePathMatcher,
77 MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, NextStepReport, build_next_report,
78 load_ranked_file_nodes_with_reasons, load_ranked_folder_nodes_with_reasons,
79 validate_federated_root_count,
80};
81use projectatlas_symbols::{extract_symbol_graph_controlled, semantic_resolution_contract_digest};
82use rayon::ThreadPoolBuilder;
83use rayon::prelude::*;
84use serde::{Deserialize, Serialize};
85use serde_json::{Value, json};
86use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
87use std::fmt;
88use std::fmt::Write as _;
89use std::fs;
90use std::io::{self, Read};
91use std::path::{Path, PathBuf};
92use std::process::{Command as StdCommand, Stdio};
93use std::sync::Arc;
94use std::sync::atomic::{AtomicBool, Ordering};
95use std::sync::mpsc::{self, RecvTimeoutError, TrySendError};
96use std::thread;
97use std::time::{Duration, Instant};
98
99pub(crate) const MAX_SYMBOL_FILE_BYTES: u64 = 2_000_000;
101pub(crate) const DEFAULT_HEALTH_LIMIT: usize = 50;
103pub(crate) const MAX_HEALTH_LIMIT: usize = 200;
105pub(crate) const MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES: u64 = 2 * 1_024 * 1_024;
107const MAX_GIT_CONFIG_QUERY_OUTPUT_BYTES: usize = 64 * 1_024;
109const GIT_CONFIG_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
111
112const DEFAULT_INDEX_WORK_TIMEOUT: Duration = Duration::from_secs(30 * 60);
114const MAX_STAGED_TEXT_BYTES: u64 = 512 * 1024 * 1024;
116const MAX_PUBLICATION_STAGING_BYTES: u64 = 2 * 1024 * 1024 * 1024;
118const PUBLICATION_NODE_BATCH_SIZE: usize = 1_024;
120const PUBLICATION_PATH_BATCH_SIZE: usize = 128;
122const PUBLICATION_TEXT_BATCH_SIZE: usize = 32;
124#[cfg(not(feature = "optional-parser-supervisor"))]
126const SYMBOL_PARSE_BATCH_SIZE: usize = 64;
127const MAX_SYMBOL_PARSE_JOBS: usize = 100_000;
129const MAX_INCREMENTAL_CHANGED_PATHS: usize = 100_000;
131const MAX_INCREMENTAL_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
133const WATCH_EVENT_QUEUE_CAPACITY: usize = 1_024;
135pub(crate) const INDEX_WORKER_SAFE_CEILING: usize = 32;
137const CONTROLLED_SOURCE_READ_BUFFER_BYTES: usize = 8_192;
139const MAX_PURPOSE_IMPORT_BYTES: u64 = 512 * 1_024 * 1_024;
141const MAX_PURPOSE_INPUT_FILE_BYTES: u64 = 16 * 1_024 * 1_024;
143const MAX_PURPOSE_REVIEW_PATH_BYTES: usize = 4 * 1_024;
145const MAX_PURPOSE_REVIEW_FIELD_BYTES: usize = 64 * 1_024;
147const PURPOSE_REVIEW_REPORT_ERROR_FIELD: &str = "error";
149const MAX_PURPOSE_REVIEW_INPUT_BYTES: usize = 512 * 1_024;
151const MAX_PURPOSE_REVIEW_REPORT_BYTES: usize = 4 * 1_024 * 1_024;
153const MAX_PURPOSE_HEADER_BYTES: u64 = 256 * 1_024;
155const MAX_PURPOSE_IMPORT_RECORDS: u64 = 1_000_000;
157
158const BUILTIN_PROJECTATLAS_PURPOSES: &[(&str, &str)] = &[
160 (
161 ".projectatlas",
162 "Store project-local ProjectAtlas metadata, configuration, and runtime state.",
163 ),
164 (
165 ".projectatlas/config.toml",
166 "Configure project-local ProjectAtlas scan, lint, purpose, and output policy.",
167 ),
168 (
169 ".projectatlas/projectatlas-nonsource-files.toon",
170 "Declare project-local non-source file purposes for ProjectAtlas map compatibility.",
171 ),
172 (
173 ".projectatlas/projectatlas-purpose-review.json",
174 "Replay agent-reviewed ProjectAtlas purpose records into the local SQLite index.",
175 ),
176];
177const CORE_INDEX_POLICY_PATHS: &[&str] = &[".projectatlas/config.toml", "projectatlas.toml"];
179
180pub(crate) struct ScanRuntimePlan {
182 pub(crate) root: PathBuf,
184 pub(crate) config: Option<atlas_map::AtlasMapConfig>,
186 selected_config_path: Option<PathBuf>,
188 config_path_override: Option<PathBuf>,
190 pub(crate) scan_options: ScanOptions,
192 pub(crate) text_options: TextIndexOptions,
194 text_index_max_bytes_override: Option<u64>,
196 #[cfg(feature = "optional-parser-supervisor")]
198 optional_parser_selection: OptionalParserPackProjectSelection,
199}
200
201struct PurposeImportSnapshot {
203 records: Vec<atlas_map::ImportedPurposeRecord>,
205 fingerprint: String,
207}
208
209#[derive(Clone, Copy)]
211struct PurposeImportLimits {
212 total_bytes: u64,
214 complete_file_bytes: u64,
216 header_bytes: u64,
218 records: u64,
220}
221
222impl Default for PurposeImportLimits {
223 fn default() -> Self {
224 Self {
225 total_bytes: MAX_PURPOSE_IMPORT_BYTES,
226 complete_file_bytes: MAX_PURPOSE_INPUT_FILE_BYTES,
227 header_bytes: MAX_PURPOSE_HEADER_BYTES,
228 records: MAX_PURPOSE_IMPORT_RECORDS,
229 }
230 }
231}
232
233struct PurposeInputReader<'a> {
235 control: &'a IndexWorkControl,
237 limits: PurposeImportLimits,
239 complete_paths: BTreeSet<PathBuf>,
241 purpose_filename: String,
243 complete_digests: BTreeMap<PathBuf, String>,
245}
246
247const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8;
249const NORMAL_READ_REFRESH_MAX_PATHS: usize = 64;
251const NORMAL_READ_REFRESH_MAX_BYTES: u64 = 8 * 1024 * 1024;
253const MAX_INDEXED_NAVIGATION_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
255const INDEX_DERIVATION_CONTRACT_VERSION: &str = "2";
257
258#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
260#[serde(rename_all = "snake_case")]
261pub(crate) enum IndexReadStatus {
262 InitRequired,
264 WorktreeRequired,
266 RefreshRequired,
268 VerificationIncomplete,
270 ProjectMismatch,
272}
273
274#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
276#[serde(rename_all = "snake_case")]
277pub(crate) enum IndexRefreshReason {
278 SourceChanged,
280 PathsChanged,
282 PolicyDrift,
284 DependencyClosureLimit,
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
290#[serde(rename_all = "snake_case")]
291pub(crate) enum IndexRefreshScope {
292 Incremental,
294 Full,
296}
297
298struct IndexFreshnessDelta {
300 report: IndexRefreshRequired,
302 paths: HashSet<PathBuf>,
304}
305
306#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
308pub(crate) struct SourceVerificationWork {
309 pub(crate) filesystem_entries: u64,
311 pub(crate) filesystem_bytes: u64,
313 pub(crate) sqlite_read_statements: u64,
315 pub(crate) decoded_nodes: u64,
317}
318
319struct IndexFreshnessAssessment {
321 delta: Option<IndexFreshnessDelta>,
323 work: SourceVerificationWork,
325}
326
327pub(crate) struct ExactFreshIndexRead {
329 pub(crate) store: AtlasStore,
331 pub(crate) work: SourceVerificationWork,
333}
334
335#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
337#[serde(rename_all = "snake_case")]
338pub(crate) enum IndexVerificationReason {
339 PolicyUnavailable,
341 SourceInspectionFailed,
343 SourceTooLarge,
345 ProjectIdentityUnavailable,
347 PublicationIncomplete,
349 PublicationContractMismatch,
351}
352
353#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
355pub(crate) struct IndexInitRequired {
356 pub(crate) project_root: String,
358 pub(crate) database: String,
360 pub(crate) status: IndexReadStatus,
362}
363
364#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
366pub(crate) struct ProjectWorktreeRequired {
367 pub(crate) project_root: String,
369 pub(crate) status: IndexReadStatus,
371}
372
373#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
375pub(crate) struct IndexRefreshRequired {
376 pub(crate) project_root: String,
378 pub(crate) status: IndexReadStatus,
380 pub(crate) reason: IndexRefreshReason,
382 pub(crate) scope: IndexRefreshScope,
384 pub(crate) changed: usize,
386 pub(crate) added: usize,
388 pub(crate) removed: usize,
390 pub(crate) modified: usize,
392 pub(crate) sample_paths: Vec<String>,
394}
395
396#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
398pub(crate) struct IndexVerificationIncomplete {
399 pub(crate) project_root: String,
401 pub(crate) status: IndexReadStatus,
403 pub(crate) reason: IndexVerificationReason,
405 pub(crate) scope: IndexRefreshScope,
407 pub(crate) message: String,
409}
410
411#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
413pub(crate) struct IndexProjectMismatch {
414 pub(crate) status: IndexReadStatus,
416 pub(crate) selected_project_root: String,
418 pub(crate) indexed_project_root: String,
420}
421
422impl fmt::Display for IndexRefreshRequired {
423 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
424 if self.reason == IndexRefreshReason::PolicyDrift {
425 return formatter.write_str(
426 "refresh_required: derived index policy differs from the current project configuration; run `projectatlas watch --once` or `atlas_watch_once` before retrying",
427 );
428 }
429 if self.reason == IndexRefreshReason::DependencyClosureLimit {
430 return formatter.write_str(
431 "refresh_required: the dependency-aware incremental closure exceeded its safe limit; run a complete `projectatlas scan` or `atlas_scan` before retrying",
432 );
433 }
434 write!(
435 formatter,
436 "refresh_required: {} indexed path(s) differ from current local source; run `projectatlas watch --once` or `atlas_watch_once` before retrying",
437 self.changed
438 )
439 }
440}
441
442impl fmt::Display for IndexInitRequired {
443 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
444 write!(
445 formatter,
446 "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`",
447 self.database, self.project_root
448 )
449 }
450}
451
452impl fmt::Display for ProjectWorktreeRequired {
453 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
454 write!(
455 formatter,
456 "worktree_required: '{}' is a bare/common Git directory without checked-out source; select a checked-out worktree and initialize that exact root",
457 self.project_root
458 )
459 }
460}
461
462impl fmt::Display for IndexVerificationIncomplete {
463 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
464 write!(
465 formatter,
466 "verification_incomplete: current local source could not be verified safely: {}",
467 self.message
468 )
469 }
470}
471
472impl fmt::Display for IndexProjectMismatch {
473 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
474 write!(
475 formatter,
476 "project_mismatch: selected project root '{}' does not match index root '{}'",
477 self.selected_project_root, self.indexed_project_root
478 )
479 }
480}
481
482#[cfg(test)]
484fn verify_index_freshness(
485 store: &AtlasStore,
486 root: &Path,
487 config_path: Option<&Path>,
488) -> Result<(), CliError> {
489 let plan = ScanRuntimePlan::for_path(config_path, root, None).map_err(|source| {
490 verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &source)
491 })?;
492 match detect_index_freshness(store, &plan)? {
493 Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
494 None => Ok(()),
495 }
496}
497
498pub(crate) fn open_fresh_atlas_store_for_project(
500 db_path: &Path,
501 root: &Path,
502 config_path: Option<&Path>,
503) -> Result<AtlasStore, CliError> {
504 let control = standalone_index_work_control();
505 open_fresh_atlas_store_for_project_controlled(db_path, root, config_path, &control)
506}
507
508pub(crate) fn open_fresh_atlas_store_for_project_controlled(
510 db_path: &Path,
511 root: &Path,
512 config_path: Option<&Path>,
513 control: &IndexWorkControl,
514) -> Result<AtlasStore, CliError> {
515 Ok(
516 open_exact_fresh_atlas_store_for_project_controlled(db_path, root, config_path, control)?
517 .store,
518 )
519}
520
521pub(crate) fn open_exact_fresh_atlas_store_for_project_controlled(
523 db_path: &Path,
524 root: &Path,
525 config_path: Option<&Path>,
526 control: &IndexWorkControl,
527) -> Result<ExactFreshIndexRead, CliError> {
528 open_exact_fresh_atlas_store_for_project_with_repair(
529 db_path,
530 root,
531 config_path,
532 control,
533 true,
534 ScanLimits::default(),
535 )
536}
537
538fn open_exact_fresh_atlas_store_for_project_with_repair(
540 db_path: &Path,
541 root: &Path,
542 config_path: Option<&Path>,
543 control: &IndexWorkControl,
544 repair_safe_delta: bool,
545 scan_limits: ScanLimits,
546) -> Result<ExactFreshIndexRead, CliError> {
547 let bounded_control = bounded_index_work_control(control);
548 let control = &bounded_control;
549 let store = open_atlas_store_read_only_for_project(db_path, root)?;
550 let plan = ScanRuntimePlan::for_path_controlled(config_path, root, None, control)
551 .map_err(|source| publication_input_error(root, source))?;
552 let assessment = match detect_index_freshness_controlled(&store, &plan, scan_limits, control) {
553 Ok(assessment) => assessment,
554 Err(CliError::VerificationIncomplete(report))
555 if report.reason == IndexVerificationReason::PublicationContractMismatch =>
556 {
557 return Err(CliError::RefreshRequired(Box::new(
558 index_policy_refresh_required(&plan.root),
559 )));
560 }
561 Err(error) => return Err(error),
562 };
563 let mut work = assessment.work;
564 let Some(delta) = assessment.delta else {
565 return Ok(ExactFreshIndexRead { store, work });
566 };
567 if !repair_safe_delta {
568 return Err(CliError::RefreshRequired(Box::new(delta.report)));
569 }
570 if delta.report.scope != IndexRefreshScope::Incremental {
571 return Err(CliError::RefreshRequired(Box::new(delta.report)));
572 }
573
574 let refresh_required = delta.report.clone();
575 drop(store);
576 let repair = (|| {
577 let mut writer = open_atlas_store_for_project(db_path, &plan.root)?;
578 let changes = WatchChangeSet {
579 requires_full_scan: false,
580 paths: delta.paths,
581 };
582 refresh_index_for_changes_controlled(
583 &mut writer,
584 &plan,
585 &changes,
586 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
587 control,
588 )
589 })();
590 if let Err(error) = repair {
591 if automatic_refresh_write_is_unavailable(&error) {
592 return Err(CliError::RefreshRequired(Box::new(refresh_required)));
593 }
594 return Err(error);
595 }
596
597 let store = open_atlas_store_read_only_for_project(db_path, &plan.root)?;
598 verify_index_project_root(&store, &plan.root)?;
599 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
600 verify_index_publication(&store, &plan)?;
601 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
602 Ok(ExactFreshIndexRead { store, work })
603}
604
605pub(crate) fn open_federated_atlas_stores_for_project(
607 selected_db: &Path,
608 selected_root: &Path,
609 selected_config: Option<&Path>,
610 roots: &[PathBuf],
611 control: &IndexWorkControl,
612) -> Result<Vec<FederatedStore>, CliError> {
613 validate_federated_root_count(roots.len()).map_err(CliError::Service)?;
614 let selected_root = fs::canonicalize(selected_root).map_err(|source| CliError::Io {
615 path: selected_root.to_path_buf(),
616 source,
617 })?;
618 let mut canonical_roots = Vec::with_capacity(roots.len());
619 for root in roots {
620 let root = fs::canonicalize(root).map_err(|source| CliError::Io {
621 path: root.clone(),
622 source,
623 })?;
624 if canonical_roots.contains(&root) {
625 return Err(CliError::Service(
626 projectatlas_service::ServiceError::InvalidInput(
627 "federated roots must be unique".to_string(),
628 ),
629 ));
630 }
631 canonical_roots.push(root);
632 }
633 if canonical_roots.first() != Some(&selected_root) {
634 return Err(CliError::Service(
635 projectatlas_service::ServiceError::InvalidInput(
636 "the first federated root must be the selected project root".to_string(),
637 ),
638 ));
639 }
640
641 let databases = canonical_roots
642 .iter()
643 .enumerate()
644 .map(|(order, root)| {
645 if order == 0 {
646 selected_db.to_path_buf()
647 } else {
648 root.join(".projectatlas").join("projectatlas.db")
649 }
650 })
651 .collect::<Vec<_>>();
652 let mut database_bytes = 0_u64;
653 for database in &databases {
654 let metadata = fs::metadata(database).map_err(|source| CliError::Io {
655 path: database.clone(),
656 source,
657 })?;
658 if !metadata.is_file() {
659 return Err(CliError::Service(
660 projectatlas_service::ServiceError::InvalidInput(
661 "federated database path is not a regular file".to_string(),
662 ),
663 ));
664 }
665 database_bytes = database_bytes.checked_add(metadata.len()).ok_or_else(|| {
666 CliError::Service(projectatlas_service::ServiceError::InvalidInput(
667 "participating database byte count overflowed".to_string(),
668 ))
669 })?;
670 if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
671 return Err(CliError::Service(
672 projectatlas_service::ServiceError::InvalidInput(format!(
673 "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
674 )),
675 ));
676 }
677 }
678
679 let default_scan_limits = ScanLimits::default();
680 let mut remaining_input_bytes = MAX_FEDERATED_INPUT_BYTES;
681 let mut stores: Vec<FederatedStore> = Vec::with_capacity(canonical_roots.len());
682 for (order, (root, database)) in canonical_roots.into_iter().zip(databases).enumerate() {
683 let started = Instant::now();
684 let config = (order == 0).then_some(selected_config).flatten();
685 let exact = match open_exact_fresh_atlas_store_for_project_with_repair(
686 &database,
687 &root,
688 config,
689 control,
690 false,
691 ScanLimits::new(
692 default_scan_limits.max_entries(),
693 remaining_input_bytes,
694 default_scan_limits.max_workers(),
695 ),
696 ) {
697 Ok(exact) => exact,
698 Err(error) => {
699 for store in stores {
700 drop(store.finish());
701 }
702 return Err(error);
703 }
704 };
705 remaining_input_bytes = remaining_input_bytes.saturating_sub(exact.work.filesystem_bytes);
706 let input_work = FederatedInputWork {
707 filesystem_entries: exact.work.filesystem_entries,
708 filesystem_bytes: exact.work.filesystem_bytes,
709 sqlite_read_statements: exact.work.sqlite_read_statements,
710 decoded_nodes: exact.work.decoded_nodes,
711 elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
712 };
713 match FederatedStore::new(exact.store, database, root, input_work) {
714 Ok(store) => stores.push(store),
715 Err(error) => {
716 for store in stores {
717 drop(store.finish());
718 }
719 return Err(CliError::Service(error));
720 }
721 }
722 }
723 Ok(stores)
724}
725
726fn automatic_refresh_write_is_unavailable(error: &CliError) -> bool {
728 matches!(error, CliError::Db(source) if source.is_write_unavailable())
729}
730
731fn index_policy_refresh_required(root: &Path) -> IndexRefreshRequired {
733 IndexRefreshRequired {
734 project_root: normalize_native_path_display(root),
735 status: IndexReadStatus::RefreshRequired,
736 reason: IndexRefreshReason::PolicyDrift,
737 scope: IndexRefreshScope::Full,
738 changed: 0,
739 added: 0,
740 removed: 0,
741 modified: 0,
742 sample_paths: Vec::new(),
743 }
744}
745
746#[cfg(test)]
748fn detect_index_freshness(
749 store: &AtlasStore,
750 plan: &ScanRuntimePlan,
751) -> Result<Option<IndexFreshnessDelta>, CliError> {
752 let control = standalone_index_work_control();
753 Ok(detect_index_freshness_controlled(store, plan, ScanLimits::default(), &control)?.delta)
754}
755
756fn detect_index_freshness_controlled(
758 store: &AtlasStore,
759 plan: &ScanRuntimePlan,
760 scan_limits: ScanLimits,
761 control: &IndexWorkControl,
762) -> Result<IndexFreshnessAssessment, CliError> {
763 let mut work = SourceVerificationWork::default();
764 verify_index_project_root(store, &plan.root)?;
765 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
766 verify_index_publication(store, plan)?;
767 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
768 let scan = scan_repo_controlled_with_work(&plan.root, &plan.scan_options, scan_limits, control)
769 .map_err(|source| source_inspection_error(&plan.root, source))?;
770 work.filesystem_entries = scan.work.entries;
771 work.filesystem_bytes = scan.work.source_bytes;
772 let current_nodes = scan.nodes;
773 let indexed_nodes = store
774 .load_nodes()?
775 .into_iter()
776 .map(|indexed| indexed.node)
777 .collect::<Vec<_>>();
778 work.sqlite_read_statements = work.sqlite_read_statements.saturating_add(1);
779 work.decoded_nodes = u64::try_from(indexed_nodes.len()).unwrap_or(u64::MAX);
780 Ok(IndexFreshnessAssessment {
781 delta: source_node_delta(&plan.root, ¤t_nodes, &indexed_nodes),
782 work,
783 })
784}
785
786fn verify_source_nodes_match(
788 root: &Path,
789 current_nodes: &[Node],
790 indexed_nodes: &[Node],
791) -> Result<(), CliError> {
792 match source_node_delta(root, current_nodes, indexed_nodes) {
793 Some(delta) => Err(CliError::RefreshRequired(Box::new(delta.report))),
794 None => Ok(()),
795 }
796}
797
798fn source_node_delta(
800 root: &Path,
801 current_nodes: &[Node],
802 indexed_nodes: &[Node],
803) -> Option<IndexFreshnessDelta> {
804 let current_by_path = current_nodes
805 .iter()
806 .map(|node| (node.path.as_str(), node))
807 .collect::<BTreeMap<_, _>>();
808 let indexed_by_path = indexed_nodes
809 .iter()
810 .map(|node| (node.path.as_str(), node))
811 .collect::<BTreeMap<_, _>>();
812
813 let added_paths = current_by_path
814 .keys()
815 .filter(|path| !indexed_by_path.contains_key(**path))
816 .copied()
817 .collect::<Vec<_>>();
818 let removed_paths = indexed_by_path
819 .keys()
820 .filter(|path| !current_by_path.contains_key(**path))
821 .copied()
822 .collect::<Vec<_>>();
823 let mut modified_paths = Vec::new();
824 for (path, current) in ¤t_by_path {
825 let Some(indexed) = indexed_by_path.get(path) else {
826 continue;
827 };
828 if !same_indexed_source(current, indexed) {
829 modified_paths.push(*path);
830 }
831 }
832 let changed = added_paths
833 .len()
834 .saturating_add(removed_paths.len())
835 .saturating_add(modified_paths.len());
836 if changed == 0 {
837 return None;
838 }
839
840 let changed_paths = added_paths
841 .iter()
842 .chain(&removed_paths)
843 .chain(&modified_paths)
844 .copied()
845 .collect::<BTreeSet<_>>()
846 .into_iter()
847 .collect::<Vec<_>>();
848 let changed_bytes = added_paths
849 .iter()
850 .chain(&modified_paths)
851 .filter_map(|path| current_by_path.get(path).and_then(|node| node.size_bytes))
852 .fold(0_u64, u64::saturating_add);
853 let requires_full_scan = changed > NORMAL_READ_REFRESH_MAX_PATHS
854 || changed_bytes > NORMAL_READ_REFRESH_MAX_BYTES
855 || changed_paths
856 .iter()
857 .any(|path| watch_path_requires_full_scan(root, &root.join(repo_path_to_native(path))));
858 let sample_paths = changed_paths
859 .iter()
860 .take(INDEX_FRESHNESS_SAMPLE_LIMIT)
861 .map(|path| (*path).to_string())
862 .collect();
863 Some(IndexFreshnessDelta {
864 report: IndexRefreshRequired {
865 project_root: normalize_native_path_display(root),
866 status: IndexReadStatus::RefreshRequired,
867 reason: if added_paths.is_empty() && removed_paths.is_empty() {
868 IndexRefreshReason::SourceChanged
869 } else {
870 IndexRefreshReason::PathsChanged
871 },
872 scope: if requires_full_scan {
873 IndexRefreshScope::Full
874 } else {
875 IndexRefreshScope::Incremental
876 },
877 changed,
878 added: added_paths.len(),
879 removed: removed_paths.len(),
880 modified: modified_paths.len(),
881 sample_paths,
882 },
883 paths: changed_paths
884 .into_iter()
885 .map(|path| root.join(repo_path_to_native(path)))
886 .collect(),
887 })
888}
889
890fn verify_index_project_root(store: &AtlasStore, selected_root: &Path) -> Result<(), CliError> {
892 let Some(indexed_root) = store.project_root()? else {
893 return Err(verification_incomplete(
894 selected_root,
895 IndexVerificationReason::ProjectIdentityUnavailable,
896 &CliError::InvalidInput("index project root metadata is missing".to_string()),
897 ));
898 };
899 let indexed_root_path = PathBuf::from(&indexed_root);
900 let indexed_root = canonical_project_root(&indexed_root_path).map_err(|source| {
901 verification_incomplete(
902 selected_root,
903 IndexVerificationReason::ProjectIdentityUnavailable,
904 &source,
905 )
906 })?;
907 let selected_root = canonical_project_root(selected_root).map_err(|source| {
908 verification_incomplete(
909 selected_root,
910 IndexVerificationReason::SourceInspectionFailed,
911 &source,
912 )
913 })?;
914 if indexed_root != selected_root {
915 return Err(CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
916 status: IndexReadStatus::ProjectMismatch,
917 selected_project_root: normalize_native_path_display(selected_root),
918 indexed_project_root: normalize_native_path_display(indexed_root),
919 })));
920 }
921 Ok(())
922}
923
924fn verify_index_publication(store: &AtlasStore, plan: &ScanRuntimePlan) -> Result<(), CliError> {
926 let expected_fingerprint = plan.publication_contract_fingerprint();
927 let Some(publication) = store.index_publication()? else {
928 return Err(verification_incomplete(
929 &plan.root,
930 IndexVerificationReason::PublicationIncomplete,
931 &CliError::InvalidInput(
932 "derived index publication state is missing; run one refresh".to_string(),
933 ),
934 ));
935 };
936 if publication.state == IndexPublicationState::Updating {
937 return Err(verification_incomplete(
938 &plan.root,
939 IndexVerificationReason::PublicationIncomplete,
940 &CliError::InvalidInput(
941 "a prior derived index publication did not complete; run one refresh".to_string(),
942 ),
943 ));
944 }
945 if publication.generation == projectatlas_core::IndexGeneration::ZERO {
946 return Err(verification_incomplete(
947 &plan.root,
948 IndexVerificationReason::PublicationIncomplete,
949 &CliError::InvalidInput(
950 "derived index has no complete publication generation; run one refresh".to_string(),
951 ),
952 ));
953 }
954 if publication.contract_fingerprint.as_deref() != Some(expected_fingerprint.as_str()) {
955 return Err(verification_incomplete(
956 &plan.root,
957 IndexVerificationReason::PublicationContractMismatch,
958 &CliError::InvalidInput(
959 "derived index parser or scan-policy contract changed; run one refresh".to_string(),
960 ),
961 ));
962 }
963 Ok(())
964}
965
966fn publication_contract_matches(
968 store: &AtlasStore,
969 plan: &ScanRuntimePlan,
970) -> Result<bool, CliError> {
971 let Some(publication) = store.index_publication()? else {
972 return Ok(false);
973 };
974 Ok(publication.state == IndexPublicationState::Complete
975 && publication.generation != IndexGeneration::ZERO
976 && publication.contract_fingerprint.as_deref()
977 == Some(plan.publication_contract_fingerprint().as_str()))
978}
979
980fn index_derivation_fingerprint(
982 scan_options: &ScanOptions,
983 text_options: TextIndexOptions,
984 #[cfg(feature = "optional-parser-supervisor")]
985 optional_parser_selection: &OptionalParserPackProjectSelection,
986) -> String {
987 index_derivation_fingerprint_with_semantic_digest(
988 scan_options,
989 text_options,
990 #[cfg(feature = "optional-parser-supervisor")]
991 optional_parser_selection,
992 &semantic_resolution_contract_digest(),
993 )
994}
995
996fn index_derivation_fingerprint_with_semantic_digest(
998 scan_options: &ScanOptions,
999 text_options: TextIndexOptions,
1000 #[cfg(feature = "optional-parser-supervisor")]
1001 optional_parser_selection: &OptionalParserPackProjectSelection,
1002 semantic_resolution_digest: &str,
1003) -> String {
1004 let mut hasher = Hasher::new();
1005 hash_index_contract_value(
1006 &mut hasher,
1007 "contract_version",
1008 INDEX_DERIVATION_CONTRACT_VERSION,
1009 );
1010 hash_index_contract_value(
1011 &mut hasher,
1012 "language_registry_version",
1013 &LANGUAGE_CAPABILITY_REGISTRY_VERSION.to_string(),
1014 );
1015 hash_index_contract_value(
1016 &mut hasher,
1017 "accepted_language_set_version",
1018 &ACCEPTED_LANGUAGE_CAPABILITY_SET_VERSION.to_string(),
1019 );
1020 hash_index_contract_value(
1021 &mut hasher,
1022 "language_registry_digest",
1023 &language_registry_digest(),
1024 );
1025 hash_index_contract_value(
1026 &mut hasher,
1027 "accepted_language_set_digest",
1028 &accepted_language_capability_digest(),
1029 );
1030 hash_index_contract_value(
1031 &mut hasher,
1032 "semantic_resolution_contract_digest",
1033 semantic_resolution_digest,
1034 );
1035 for value in &scan_options.exclude_dir_names {
1036 hash_index_contract_value(&mut hasher, "exclude_dir_name", value);
1037 }
1038 for value in &scan_options.exclude_dir_suffixes {
1039 hash_index_contract_value(&mut hasher, "exclude_dir_suffix", value);
1040 }
1041 for value in &scan_options.exclude_path_prefixes {
1042 hash_index_contract_value(&mut hasher, "exclude_path_prefix", value);
1043 }
1044 for (selector, language) in &scan_options.language_overrides {
1045 hash_index_contract_value(&mut hasher, "language_override_selector", selector);
1046 hash_index_contract_value(&mut hasher, "language_override_target", language);
1047 }
1048 hash_index_contract_value(
1049 &mut hasher,
1050 "text_index_max_bytes",
1051 &text_options.max_bytes.to_string(),
1052 );
1053 #[cfg(feature = "optional-parser-supervisor")]
1054 hash_index_contract_value(
1055 &mut hasher,
1056 "optional_parser_selection",
1057 optional_parser_selection
1058 .selection_key()
1059 .map_or("inactive", |selection| selection.as_str()),
1060 );
1061 hasher.finalize().to_hex().to_string()
1062}
1063
1064#[cfg(test)]
1066fn revalidate_index_publication_inputs_controlled(
1067 store: &AtlasStore,
1068 plan: &ScanRuntimePlan,
1069 expected_purpose_import_fingerprint: Option<&str>,
1070 control: &IndexWorkControl,
1071) -> Result<(), CliError> {
1072 revalidate_index_publication_inputs_controlled_with_limits(
1073 store,
1074 plan,
1075 expected_purpose_import_fingerprint,
1076 control,
1077 PurposeImportLimits::default(),
1078 )
1079}
1080
1081#[cfg(test)]
1083fn revalidate_index_publication_inputs_controlled_with_limits(
1084 store: &AtlasStore,
1085 plan: &ScanRuntimePlan,
1086 expected_purpose_import_fingerprint: Option<&str>,
1087 control: &IndexWorkControl,
1088 purpose_limits: PurposeImportLimits,
1089) -> Result<(), CliError> {
1090 let staged_nodes = store
1091 .load_nodes()?
1092 .into_iter()
1093 .map(|indexed| indexed.node)
1094 .collect::<Vec<_>>();
1095 revalidate_staged_publication_inputs_controlled_with_limits(
1096 plan,
1097 &staged_nodes,
1098 expected_purpose_import_fingerprint,
1099 None,
1100 control,
1101 purpose_limits,
1102 )
1103}
1104
1105fn revalidate_staged_publication_inputs_controlled(
1107 plan: &ScanRuntimePlan,
1108 staged_nodes: &[Node],
1109 expected_purpose_import_fingerprint: Option<&str>,
1110 control: &IndexWorkControl,
1111) -> Result<(), CliError> {
1112 revalidate_staged_publication_inputs_controlled_with_limits(
1113 plan,
1114 staged_nodes,
1115 expected_purpose_import_fingerprint,
1116 None,
1117 control,
1118 PurposeImportLimits::default(),
1119 )
1120}
1121
1122fn revalidate_staged_publication_inputs_with_purpose_snapshot(
1124 plan: &ScanRuntimePlan,
1125 staged_nodes: &[Node],
1126 purpose_import: Option<&PurposeImportSnapshot>,
1127 control: &IndexWorkControl,
1128) -> Result<(), CliError> {
1129 revalidate_staged_publication_inputs_controlled_with_limits(
1130 plan,
1131 staged_nodes,
1132 purpose_import.map(|snapshot| snapshot.fingerprint.as_str()),
1133 purpose_import.map(|snapshot| snapshot.records.as_slice()),
1134 control,
1135 PurposeImportLimits::default(),
1136 )
1137}
1138
1139fn revalidate_staged_publication_inputs_controlled_with_limits(
1141 plan: &ScanRuntimePlan,
1142 staged_nodes: &[Node],
1143 expected_purpose_import_fingerprint: Option<&str>,
1144 reusable_purpose_records: Option<&[atlas_map::ImportedPurposeRecord]>,
1145 control: &IndexWorkControl,
1146 purpose_limits: PurposeImportLimits,
1147) -> Result<(), CliError> {
1148 control.check(IndexWorkStage::Publication)?;
1149 let current_plan = plan
1150 .reload_controlled_with_limits(control, purpose_limits)
1151 .map_err(|source| publication_input_error(&plan.root, source))?;
1152 control.check(IndexWorkStage::Publication)?;
1153 let staged_fingerprint = plan.publication_contract_fingerprint();
1154 let current_fingerprint = current_plan.publication_contract_fingerprint();
1155 if staged_fingerprint != current_fingerprint {
1156 return Err(verification_incomplete(
1157 &plan.root,
1158 IndexVerificationReason::PublicationContractMismatch,
1159 &CliError::InvalidInput(
1160 "derived index policy changed while publication was being built; retry the refresh"
1161 .to_string(),
1162 ),
1163 ));
1164 }
1165 let current_nodes = scan_repo_controlled(
1166 ¤t_plan.root,
1167 ¤t_plan.scan_options,
1168 ScanLimits::default(),
1169 control,
1170 )
1171 .map_err(|source| source_inspection_error(¤t_plan.root, source))?;
1172 verify_source_nodes_match(¤t_plan.root, ¤t_nodes, staged_nodes)?;
1173 if let Some(expected_fingerprint) = expected_purpose_import_fingerprint {
1174 let current_fingerprint = if let Some(records) = reusable_purpose_records {
1175 current_plan
1176 .purpose_import_fingerprint_for_records_controlled_with_limits(
1177 records,
1178 control,
1179 purpose_limits,
1180 )
1181 .map_err(|source| publication_input_error(&plan.root, source))?
1182 } else {
1183 current_plan
1184 .purpose_import_snapshot_controlled_with_limits(
1185 ¤t_nodes,
1186 control,
1187 purpose_limits,
1188 )
1189 .map_err(|source| publication_input_error(&plan.root, source))?
1190 .fingerprint
1191 };
1192 if current_fingerprint != expected_fingerprint {
1193 return Err(verification_incomplete(
1194 &plan.root,
1195 IndexVerificationReason::PublicationContractMismatch,
1196 &CliError::InvalidInput(
1197 "purpose-import inputs changed while publication was being built; retry the refresh"
1198 .to_string(),
1199 ),
1200 ));
1201 }
1202 }
1203 control.check(IndexWorkStage::Publication)?;
1204 Ok(())
1205}
1206
1207fn publication_input_error(root: &Path, source: CliError) -> CliError {
1209 match source {
1210 source @ CliError::IndexWork(_) => source,
1211 other => verification_incomplete(root, IndexVerificationReason::PolicyUnavailable, &other),
1212 }
1213}
1214
1215fn source_inspection_error(root: &Path, source: FsError) -> CliError {
1217 match source {
1218 FsError::IndexWork(failure) => failure.into(),
1219 FsError::RepositoryBoundary { .. } => {
1220 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1221 project_root: normalize_native_path_display(root),
1222 status: IndexReadStatus::VerificationIncomplete,
1223 reason: IndexVerificationReason::PolicyUnavailable,
1224 scope: IndexRefreshScope::Full,
1225 message: source.to_string(),
1226 }))
1227 }
1228 other => CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1229 project_root: normalize_native_path_display(root),
1230 status: IndexReadStatus::VerificationIncomplete,
1231 reason: IndexVerificationReason::SourceInspectionFailed,
1232 scope: IndexRefreshScope::Full,
1233 message: other.to_string(),
1234 })),
1235 }
1236}
1237
1238fn complete_index_publication(
1240 publication: IndexPublicationGuard<'_>,
1241 control: &IndexWorkControl,
1242) -> Result<(), CliError> {
1243 control.check(IndexWorkStage::Publication)?;
1244 publication.complete()?;
1245 Ok(())
1246}
1247
1248fn hash_index_contract_value(hasher: &mut Hasher, field: &str, value: &str) {
1250 hasher.update(field.as_bytes());
1251 hasher.update(&[0]);
1252 hasher.update(value.as_bytes());
1253 hasher.update(&[0xff]);
1254}
1255
1256fn verification_incomplete(
1258 root: &Path,
1259 reason: IndexVerificationReason,
1260 source: &CliError,
1261) -> CliError {
1262 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
1263 project_root: normalize_native_path_display(root),
1264 status: IndexReadStatus::VerificationIncomplete,
1265 reason,
1266 scope: IndexRefreshScope::Full,
1267 message: source.to_string(),
1268 }))
1269}
1270
1271fn same_indexed_source(current: &Node, indexed: &Node) -> bool {
1273 current.path == indexed.path
1274 && current.kind == indexed.kind
1275 && current.parent_path == indexed.parent_path
1276 && current.extension == indexed.extension
1277 && current.language == indexed.language
1278 && current.size_bytes == indexed.size_bytes
1279 && current.content_hash == indexed.content_hash
1280}
1281
1282fn source_changed_during_derivation(root: &Path, path: &str) -> CliError {
1284 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
1285 project_root: normalize_native_path_display(root),
1286 status: IndexReadStatus::RefreshRequired,
1287 reason: IndexRefreshReason::SourceChanged,
1288 scope: IndexRefreshScope::Full,
1289 changed: 1,
1290 added: 0,
1291 removed: 0,
1292 modified: 1,
1293 sample_paths: vec![path.to_string()],
1294 }))
1295}
1296
1297impl<'a> PurposeInputReader<'a> {
1298 fn new(
1300 plan: &ScanRuntimePlan,
1301 control: &'a IndexWorkControl,
1302 limits: PurposeImportLimits,
1303 ) -> Self {
1304 let mut complete_paths = BTreeSet::new();
1305 if let Some(path) = &plan.selected_config_path {
1306 complete_paths.insert(path.clone());
1307 }
1308 if let Some(config) = &plan.config {
1309 complete_paths.insert(config.map_path.clone());
1310 complete_paths.insert(config.nonsource_files_path.clone());
1311 }
1312 Self::for_complete_paths(
1313 control,
1314 limits,
1315 complete_paths,
1316 plan.config.as_ref().map_or_else(
1317 || ".purpose".to_string(),
1318 |config| config.purpose_filename().to_string(),
1319 ),
1320 )
1321 }
1322
1323 fn for_complete_paths(
1325 control: &'a IndexWorkControl,
1326 limits: PurposeImportLimits,
1327 complete_paths: BTreeSet<PathBuf>,
1328 purpose_filename: String,
1329 ) -> Self {
1330 Self {
1331 control,
1332 limits,
1333 complete_paths,
1334 purpose_filename,
1335 complete_digests: BTreeMap::new(),
1336 }
1337 }
1338
1339 fn read_text(&mut self, path: &Path) -> Result<String, CliError> {
1341 self.control.check(IndexWorkStage::Publication)?;
1342 let is_complete = self.complete_paths.contains(path)
1343 || path
1344 .file_name()
1345 .is_some_and(|name| name == self.purpose_filename.as_str());
1346 let file_limit = if is_complete {
1347 self.limits.complete_file_bytes
1348 } else {
1349 self.limits.header_bytes
1350 };
1351 let mut file = fs::File::open(path).map_err(|source| CliError::Io {
1352 path: path.to_path_buf(),
1353 source,
1354 })?;
1355 let bytes = self.read_bytes(path, &mut file, file_limit, is_complete)?;
1356 if is_complete {
1357 self.complete_digests.insert(
1358 path.to_path_buf(),
1359 blake3::hash(&bytes).to_hex().to_string(),
1360 );
1361 }
1362 match String::from_utf8(bytes) {
1363 Ok(content) => Ok(content),
1364 Err(source) if !is_complete && source.utf8_error().error_len().is_some() => {
1365 Ok(String::new())
1366 }
1367 Err(source) if !is_complete && source.utf8_error().error_len().is_none() => {
1368 let valid_up_to = source.utf8_error().valid_up_to();
1369 String::from_utf8(source.into_bytes()[..valid_up_to].to_vec()).map_err(|source| {
1370 CliError::InvalidInput(format!(
1371 "purpose header input is not valid UTF-8 for {}: {source}",
1372 normalize_native_path_display(path)
1373 ))
1374 })
1375 }
1376 Err(source) => Err(CliError::InvalidInput(format!(
1377 "purpose input is not valid UTF-8 for {}: {source}",
1378 normalize_native_path_display(path)
1379 ))),
1380 }
1381 }
1382
1383 fn read_bytes<R: Read>(
1385 &mut self,
1386 path: &Path,
1387 reader: &mut R,
1388 file_limit: u64,
1389 require_complete: bool,
1390 ) -> Result<Vec<u8>, CliError> {
1391 let initial_capacity = usize::try_from(file_limit)
1392 .unwrap_or(usize::MAX)
1393 .min(CONTROLLED_SOURCE_READ_BUFFER_BYTES);
1394 let mut bytes = Vec::with_capacity(initial_capacity);
1395 let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
1396 loop {
1397 self.control.check(IndexWorkStage::Publication)?;
1398 let file_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
1399 let remaining = file_limit.saturating_sub(file_bytes);
1400 if remaining == 0 {
1401 if !require_complete {
1402 break;
1403 }
1404 let read = reader
1405 .read(&mut buffer[..1])
1406 .map_err(|source| CliError::Io {
1407 path: path.to_path_buf(),
1408 source,
1409 })?;
1410 if read == 0 {
1411 break;
1412 }
1413 return Err(IndexWorkFailure::resource_limit(
1414 IndexWorkStage::Publication,
1415 IndexWorkResource::PurposeBytes,
1416 file_limit,
1417 file_limit.saturating_add(1),
1418 )
1419 .into());
1420 }
1421 let read_limit = usize::try_from(remaining)
1422 .unwrap_or(usize::MAX)
1423 .min(buffer.len());
1424 let read = reader
1425 .read(&mut buffer[..read_limit])
1426 .map_err(|source| CliError::Io {
1427 path: path.to_path_buf(),
1428 source,
1429 })?;
1430 if read == 0 {
1431 break;
1432 }
1433 self.control.consume_purpose_bytes(
1434 self.limits.total_bytes,
1435 u64::try_from(read).unwrap_or(u64::MAX),
1436 )?;
1437 bytes.extend_from_slice(&buffer[..read]);
1438 }
1439 self.control.check(IndexWorkStage::Publication)?;
1440 Ok(bytes)
1441 }
1442
1443 fn complete_digest(&self, path: &Path) -> Option<&str> {
1445 self.complete_digests.get(path).map(String::as_str)
1446 }
1447}
1448
1449impl ScanRuntimePlan {
1450 pub(crate) fn for_path(
1452 config_path: Option<&Path>,
1453 path: &Path,
1454 text_index_max_bytes: Option<u64>,
1455 ) -> Result<Self, CliError> {
1456 let control = standalone_index_work_control();
1457 Self::for_path_controlled(config_path, path, text_index_max_bytes, &control)
1458 }
1459
1460 pub(crate) fn for_path_controlled(
1462 config_path: Option<&Path>,
1463 path: &Path,
1464 text_index_max_bytes: Option<u64>,
1465 control: &IndexWorkControl,
1466 ) -> Result<Self, CliError> {
1467 Self::for_path_controlled_with_limits(
1468 config_path,
1469 path,
1470 text_index_max_bytes,
1471 control,
1472 PurposeImportLimits::default(),
1473 )
1474 }
1475
1476 fn for_path_controlled_with_limits(
1478 config_path: Option<&Path>,
1479 path: &Path,
1480 text_index_max_bytes: Option<u64>,
1481 control: &IndexWorkControl,
1482 purpose_limits: PurposeImportLimits,
1483 ) -> Result<Self, CliError> {
1484 control.check(IndexWorkStage::Publication)?;
1485 let root = canonical_source_project_root(path)?;
1486 let selected_config_path = selected_scan_import_config_path(config_path, &root)?;
1487 let config = if let Some(path) = selected_config_path.as_deref() {
1488 let mut complete_paths = BTreeSet::new();
1489 complete_paths.insert(path.to_path_buf());
1490 let mut reader = PurposeInputReader::for_complete_paths(
1491 control,
1492 purpose_limits,
1493 complete_paths,
1494 ".purpose".to_string(),
1495 );
1496 let text = reader.read_text(path)?;
1497 let config = load_atlas_config_from_text(path, &text)?;
1498 let config_root = canonical_project_root(&config.root)?;
1499 if config_root != root {
1500 return Err(config_root_mismatch_error(path, &config_root, &root));
1501 }
1502 Some(config)
1503 } else {
1504 None
1505 };
1506 control.check(IndexWorkStage::Publication)?;
1507 let scan_options = config.as_ref().map_or_else(
1508 ScanOptions::default,
1509 atlas_map::AtlasMapConfig::scan_options,
1510 );
1511 let text_options = text_index_options(config.as_ref(), text_index_max_bytes);
1512 #[cfg(feature = "optional-parser-supervisor")]
1513 let optional_parser_selection =
1514 OptionalParserPackLifecycle::new(&root, None)?.derive_project_selection()?;
1515 #[cfg(feature = "optional-parser-supervisor")]
1516 let scan_options = {
1517 let mut scan_options = scan_options;
1518 scan_options.admit_optional_languages =
1519 optional_parser_selection.selection_key().is_some();
1520 scan_options
1521 };
1522 Ok(Self {
1523 root,
1524 config,
1525 selected_config_path,
1526 config_path_override: config_path.map(Path::to_path_buf),
1527 scan_options,
1528 text_options,
1529 text_index_max_bytes_override: text_index_max_bytes,
1530 #[cfg(feature = "optional-parser-supervisor")]
1531 optional_parser_selection,
1532 })
1533 }
1534
1535 fn reload(&self) -> Result<Self, CliError> {
1537 Self::for_path(
1538 self.config_path_override.as_deref(),
1539 &self.root,
1540 self.text_index_max_bytes_override,
1541 )
1542 }
1543
1544 fn reload_controlled(&self, control: &IndexWorkControl) -> Result<Self, CliError> {
1546 self.reload_controlled_with_limits(control, PurposeImportLimits::default())
1547 }
1548
1549 fn reload_controlled_with_limits(
1551 &self,
1552 control: &IndexWorkControl,
1553 purpose_limits: PurposeImportLimits,
1554 ) -> Result<Self, CliError> {
1555 Self::for_path_controlled_with_limits(
1556 self.config_path_override.as_deref(),
1557 &self.root,
1558 self.text_index_max_bytes_override,
1559 control,
1560 purpose_limits,
1561 )
1562 }
1563
1564 fn publication_contract_fingerprint(&self) -> String {
1569 let configured_text_options = text_index_options(self.config.as_ref(), None);
1570 index_derivation_fingerprint(
1571 &self.scan_options,
1572 configured_text_options,
1573 #[cfg(feature = "optional-parser-supervisor")]
1574 &self.optional_parser_selection,
1575 )
1576 }
1577
1578 fn purpose_import_snapshot_controlled(
1580 &self,
1581 nodes: &[Node],
1582 control: &IndexWorkControl,
1583 ) -> Result<PurposeImportSnapshot, CliError> {
1584 self.purpose_import_snapshot_controlled_with_limits(
1585 nodes,
1586 control,
1587 PurposeImportLimits::default(),
1588 )
1589 }
1590
1591 fn purpose_import_snapshot_controlled_with_limits(
1593 &self,
1594 nodes: &[Node],
1595 control: &IndexWorkControl,
1596 limits: PurposeImportLimits,
1597 ) -> Result<PurposeImportSnapshot, CliError> {
1598 control.check(IndexWorkStage::Publication)?;
1599 let mut hasher = Hasher::new();
1600 hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
1601 let Some(config) = self.config.as_ref() else {
1602 hash_index_contract_value(&mut hasher, "selected_config", "absent");
1603 return Ok(PurposeImportSnapshot {
1604 records: Vec::new(),
1605 fingerprint: hasher.finalize().to_hex().to_string(),
1606 });
1607 };
1608 let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
1609 CliError::InvalidInput(
1610 "purpose import has normalized configuration without a selected config path"
1611 .to_string(),
1612 )
1613 })?;
1614 if u64::try_from(nodes.len()).unwrap_or(u64::MAX) > limits.records {
1615 return Err(IndexWorkFailure::resource_limit(
1616 IndexWorkStage::Publication,
1617 IndexWorkResource::PurposeRecords,
1618 limits.records,
1619 u64::try_from(nodes.len()).unwrap_or(u64::MAX),
1620 )
1621 .into());
1622 }
1623 let mut reader = PurposeInputReader::new(self, control, limits);
1624 hash_publication_input_file_controlled(
1625 &mut hasher,
1626 "selected_config",
1627 selected_config_path,
1628 &mut reader,
1629 )?;
1630 let records = atlas_map::imported_purpose_records_from_nodes(config, nodes, &mut |path| {
1631 reader.read_text(path)
1632 })?;
1633 let fingerprint = finish_purpose_import_fingerprint_controlled(
1634 &mut hasher,
1635 config,
1636 &records,
1637 &mut reader,
1638 control,
1639 limits,
1640 )?;
1641 Ok(PurposeImportSnapshot {
1642 records,
1643 fingerprint,
1644 })
1645 }
1646
1647 fn purpose_import_fingerprint_for_records_controlled_with_limits(
1649 &self,
1650 records: &[atlas_map::ImportedPurposeRecord],
1651 control: &IndexWorkControl,
1652 limits: PurposeImportLimits,
1653 ) -> Result<String, CliError> {
1654 control.check(IndexWorkStage::Publication)?;
1655 let mut hasher = Hasher::new();
1656 hash_index_contract_value(&mut hasher, "purpose_import_version", "2");
1657 let Some(config) = self.config.as_ref() else {
1658 hash_index_contract_value(&mut hasher, "selected_config", "absent");
1659 return Ok(hasher.finalize().to_hex().to_string());
1660 };
1661 let selected_config_path = self.selected_config_path.as_deref().ok_or_else(|| {
1662 CliError::InvalidInput(
1663 "purpose import has normalized configuration without a selected config path"
1664 .to_string(),
1665 )
1666 })?;
1667 let mut reader = PurposeInputReader::new(self, control, limits);
1668 hash_publication_input_file_controlled(
1669 &mut hasher,
1670 "selected_config",
1671 selected_config_path,
1672 &mut reader,
1673 )?;
1674 finish_purpose_import_fingerprint_controlled(
1675 &mut hasher,
1676 config,
1677 records,
1678 &mut reader,
1679 control,
1680 limits,
1681 )
1682 }
1683}
1684
1685fn finish_purpose_import_fingerprint_controlled(
1687 hasher: &mut Hasher,
1688 config: &atlas_map::AtlasMapConfig,
1689 records: &[atlas_map::ImportedPurposeRecord],
1690 reader: &mut PurposeInputReader<'_>,
1691 control: &IndexWorkControl,
1692 limits: PurposeImportLimits,
1693) -> Result<String, CliError> {
1694 let record_count = u64::try_from(records.len()).unwrap_or(u64::MAX);
1695 if record_count > limits.records {
1696 return Err(IndexWorkFailure::resource_limit(
1697 IndexWorkStage::Publication,
1698 IndexWorkResource::PurposeRecords,
1699 limits.records,
1700 record_count,
1701 )
1702 .into());
1703 }
1704 hash_publication_input_file_controlled(hasher, "legacy_map", &config.map_path, reader)?;
1705 hash_publication_input_file_controlled(
1706 hasher,
1707 "nonsource_purposes",
1708 &config.nonsource_files_path,
1709 reader,
1710 )?;
1711 for record in records {
1712 control.check(IndexWorkStage::Publication)?;
1713 hash_index_contract_value(hasher, "purpose_path", &record.path);
1714 hash_index_contract_value(hasher, "purpose_summary", &record.summary);
1715 }
1716 Ok(hasher.finalize().to_hex().to_string())
1717}
1718
1719fn selected_scan_import_config_path(
1721 config_path: Option<&Path>,
1722 root: &Path,
1723) -> Result<Option<PathBuf>, CliError> {
1724 if let Some(config_path) = config_path {
1725 return absolute_path(config_path).map(Some);
1726 }
1727 Ok(config_candidates_for_root(root)
1728 .into_iter()
1729 .find(|candidate| candidate.exists()))
1730}
1731
1732fn hash_publication_input_file_controlled(
1734 hasher: &mut Hasher,
1735 role: &str,
1736 path: &Path,
1737 reader: &mut PurposeInputReader<'_>,
1738) -> Result<(), CliError> {
1739 hash_index_contract_value(hasher, role, &normalize_native_path_display(path));
1740 if !path.exists() {
1741 hash_index_contract_value(hasher, "input_state", "missing");
1742 return Ok(());
1743 }
1744 if reader.complete_digest(path).is_none() {
1745 let _ = reader.read_text(path)?;
1746 }
1747 let digest = reader.complete_digest(path).ok_or_else(|| {
1748 CliError::InvalidInput(format!(
1749 "purpose publication input was not read completely: {}",
1750 normalize_native_path_display(path)
1751 ))
1752 })?;
1753 hash_index_contract_value(hasher, "input_state", "present");
1754 hash_index_contract_value(hasher, "input_digest", digest);
1755 Ok(())
1756}
1757
1758#[derive(Debug, Serialize)]
1760pub(crate) struct ScanReport {
1761 pub(crate) overview: Overview,
1763 pub(crate) purpose_import: PurposeImportReport,
1765 pub(crate) text_index: TextIndexReport,
1767 pub(crate) structural_summaries: StructuralSummaryReport,
1769 pub(crate) symbols: SymbolBuildReport,
1771}
1772
1773#[derive(Debug, Default, Serialize)]
1775pub(crate) struct PurposeImportReport {
1776 pub(crate) imported: usize,
1778 pub(crate) skipped_stale: usize,
1780 pub(crate) skipped_existing: usize,
1782}
1783
1784pub(crate) struct InitBootstrapOptions {
1786 pub(crate) no_scan: bool,
1788 pub(crate) force_rescan: bool,
1790 pub(crate) text_index_max_bytes: Option<u64>,
1792}
1793
1794#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1796#[serde(rename_all = "snake_case")]
1797pub(crate) enum InitPhaseStatus {
1798 Created,
1800 Exists,
1802 Verified,
1804 Skipped,
1806 Failed,
1808}
1809
1810#[derive(Debug, Serialize)]
1812pub(crate) struct InitSetupReport {
1813 pub(crate) ok: bool,
1815 pub(crate) root: String,
1817 pub(crate) project_dir: InitPathStatus,
1819 pub(crate) config: InitPathStatus,
1821 pub(crate) nonsource_files: InitPathStatus,
1823 pub(crate) db: InitPathStatus,
1825 pub(crate) host_configs: Vec<InitHostConfigStatus>,
1827 pub(crate) scan: InitScanPhase,
1829 pub(crate) purpose_handoff: PurposeCuratorHandoff,
1831 pub(crate) next_steps: Vec<String>,
1833}
1834
1835#[derive(Debug, Serialize)]
1837pub(crate) struct InitPathStatus {
1838 pub(crate) status: InitPhaseStatus,
1840 pub(crate) path: String,
1842}
1843
1844#[derive(Debug, Serialize)]
1846pub(crate) struct InitHostConfigStatus {
1847 pub(crate) harness: &'static str,
1849 pub(crate) status: InitPhaseStatus,
1851 pub(crate) path: String,
1853 #[serde(skip_serializing_if = "Option::is_none")]
1855 pub(crate) error: Option<String>,
1856}
1857
1858#[derive(Debug, Serialize)]
1860pub(crate) struct InitScanPhase {
1861 pub(crate) status: InitPhaseStatus,
1863 pub(crate) requested: bool,
1865 pub(crate) force_rescan: bool,
1867 pub(crate) report: Option<ScanReport>,
1869 #[serde(skip_serializing_if = "Option::is_none")]
1871 pub(crate) error: Option<String>,
1872}
1873
1874#[derive(Debug, Serialize)]
1876#[allow(
1877 clippy::struct_excessive_bools,
1878 reason = "serialized host policy exposes independent capabilities and guarantees"
1879)]
1880pub(crate) struct PurposeCuratorHandoff {
1881 pub(crate) agent_harness_expected: bool,
1883 pub(crate) execution_owner: &'static str,
1885 pub(crate) recommended_subagent_reasoning: &'static str,
1887 pub(crate) main_agent_fallback: bool,
1889 pub(crate) server_started_curator: bool,
1891 pub(crate) silent_on_success: bool,
1893 pub(crate) queue: PurposeCurationPage,
1895 pub(crate) instructions: Vec<String>,
1897}
1898
1899pub(crate) fn canonical_project_root(root: &Path) -> Result<PathBuf, CliError> {
1901 root.canonicalize().map_err(|source| CliError::Io {
1902 path: root.to_path_buf(),
1903 source,
1904 })
1905}
1906
1907pub(crate) fn canonical_source_project_root(root: &Path) -> Result<PathBuf, CliError> {
1909 let root = canonical_project_root(root)?;
1910 if is_bare_git_control_root(&root)? {
1911 return Err(CliError::WorktreeRequired(Box::new(
1912 ProjectWorktreeRequired {
1913 project_root: normalize_native_path_display(&root),
1914 status: IndexReadStatus::WorktreeRequired,
1915 },
1916 )));
1917 }
1918 Ok(root)
1919}
1920
1921pub(crate) fn index_init_required(root: &Path, database: &Path) -> CliError {
1923 CliError::InitRequired(Box::new(IndexInitRequired {
1924 project_root: normalize_native_path_display(root),
1925 database: normalize_native_path_display(database),
1926 status: IndexReadStatus::InitRequired,
1927 }))
1928}
1929
1930fn is_bare_git_control_root(root: &Path) -> Result<bool, CliError> {
1932 let git = root.join(".git");
1933 let control_root = match fs::metadata(&git) {
1934 Ok(metadata) if metadata.is_file() => return Ok(false),
1935 Ok(metadata) if metadata.is_dir() => git,
1936 Ok(_) => return Ok(false),
1937 Err(source) if source.kind() == io::ErrorKind::NotFound => root.to_path_buf(),
1938 Err(source) => {
1939 return Err(CliError::Io { path: git, source });
1940 }
1941 };
1942 let head = control_root.join("HEAD");
1943 let config = control_root.join("config");
1944 let objects = control_root.join("objects");
1945 let refs = control_root.join("refs");
1946 for path in [&head, &config, &objects, &refs] {
1947 if !path.try_exists().map_err(|source| CliError::Io {
1948 path: path.clone(),
1949 source,
1950 })? {
1951 return Ok(false);
1952 }
1953 }
1954 let structurally_git = fs::metadata(&head)
1955 .map_err(|source| CliError::Io { path: head, source })?
1956 .is_file()
1957 && fs::metadata(&config)
1958 .map_err(|source| CliError::Io {
1959 path: config.clone(),
1960 source,
1961 })?
1962 .is_file()
1963 && fs::metadata(&objects)
1964 .map_err(|source| CliError::Io {
1965 path: objects,
1966 source,
1967 })?
1968 .is_dir()
1969 && fs::metadata(&refs)
1970 .map_err(|source| CliError::Io { path: refs, source })?
1971 .is_dir();
1972 if !structurally_git {
1973 return Ok(false);
1974 }
1975 if control_root == root {
1976 return Ok(true);
1977 }
1978
1979 effective_git_config_bare_setting(&control_root, &config).map(|bare| bare.unwrap_or(false))
1980}
1981
1982fn effective_git_config_bare_setting(
1984 control_root: &Path,
1985 config: &Path,
1986) -> Result<Option<bool>, CliError> {
1987 let mut child = StdCommand::new("git")
1988 .arg("--git-dir")
1989 .arg(normalize_native_path_display(control_root))
1990 .args([
1991 "config",
1992 "--local",
1993 "--includes",
1994 "--get",
1995 "--bool",
1996 "core.bare",
1997 ])
1998 .stdin(Stdio::null())
1999 .stdout(Stdio::piped())
2000 .stderr(Stdio::piped())
2001 .spawn()
2002 .map_err(|source| CliError::Io {
2003 path: config.to_path_buf(),
2004 source,
2005 })?;
2006 let deadline = Instant::now() + GIT_CONFIG_QUERY_TIMEOUT;
2007 loop {
2008 match child.try_wait().map_err(|source| CliError::Io {
2009 path: config.to_path_buf(),
2010 source,
2011 })? {
2012 Some(_) => break,
2013 None if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
2014 None => {
2015 child.kill().map_err(|source| CliError::Io {
2016 path: config.to_path_buf(),
2017 source,
2018 })?;
2019 child.wait().map_err(|source| CliError::Io {
2020 path: config.to_path_buf(),
2021 source,
2022 })?;
2023 return Err(CliError::Io {
2024 path: config.to_path_buf(),
2025 source: io::Error::new(
2026 io::ErrorKind::TimedOut,
2027 "effective Git config query exceeded its deadline",
2028 ),
2029 });
2030 }
2031 }
2032 }
2033 let output = child.wait_with_output().map_err(|source| CliError::Io {
2034 path: config.to_path_buf(),
2035 source,
2036 })?;
2037 if output.stdout.len().saturating_add(output.stderr.len()) > MAX_GIT_CONFIG_QUERY_OUTPUT_BYTES {
2038 return Err(CliError::Io {
2039 path: config.to_path_buf(),
2040 source: io::Error::new(
2041 io::ErrorKind::InvalidData,
2042 "effective Git config query exceeded its output bound",
2043 ),
2044 });
2045 }
2046 if output.status.success() {
2047 return match std::str::from_utf8(&output.stdout)
2048 .map(str::trim)
2049 .map_err(|source| CliError::Io {
2050 path: config.to_path_buf(),
2051 source: io::Error::new(io::ErrorKind::InvalidData, source),
2052 })? {
2053 "true" => Ok(Some(true)),
2054 "false" => Ok(Some(false)),
2055 value => Err(CliError::Io {
2056 path: config.to_path_buf(),
2057 source: io::Error::new(
2058 io::ErrorKind::InvalidData,
2059 format!("Git returned invalid core.bare value {value:?}"),
2060 ),
2061 }),
2062 };
2063 }
2064 if output.status.code() == Some(1) {
2065 return Ok(None);
2066 }
2067 Err(CliError::Io {
2068 path: config.to_path_buf(),
2069 source: io::Error::new(
2070 io::ErrorKind::InvalidData,
2071 format!(
2072 "effective Git config query failed: {}",
2073 String::from_utf8_lossy(&output.stderr).trim()
2074 ),
2075 ),
2076 })
2077}
2078
2079pub(crate) fn load_scan_import_config(
2081 config_path: Option<&Path>,
2082 scan_path: &Path,
2083) -> Result<Option<atlas_map::AtlasMapConfig>, CliError> {
2084 if let Some(config_path) = config_path {
2085 return Ok(Some(load_atlas_config(Some(config_path))?));
2086 }
2087 let project_config = scan_path.join(".projectatlas").join("config.toml");
2088 if project_config.exists() {
2089 return Ok(Some(load_atlas_config(Some(&project_config))?));
2090 }
2091 let flat_config = scan_path.join("projectatlas.toml");
2092 if flat_config.exists() {
2093 return Ok(Some(load_atlas_config(Some(&flat_config))?));
2094 }
2095 Ok(None)
2096}
2097
2098pub(crate) fn open_atlas_store_for_project(
2100 path: &Path,
2101 root: &Path,
2102) -> Result<AtlasStore, CliError> {
2103 open_atlas_store_for_project_with_location_validator(path, root, validate_database_location)
2104}
2105
2106fn open_atlas_store_for_project_with_location_validator<F>(
2108 path: &Path,
2109 root: &Path,
2110 validate_location: F,
2111) -> Result<AtlasStore, CliError>
2112where
2113 F: FnOnce(&Path) -> projectatlas_db::DbResult<()>,
2114{
2115 validate_location(path).map_err(project_store_error)?;
2116 ensure_parent_dir(path)?;
2117 AtlasStore::open_for_project(path, root).map_err(project_store_error)
2118}
2119
2120pub(crate) fn open_atlas_store_read_only_for_project(
2122 path: &Path,
2123 root: &Path,
2124) -> Result<AtlasStore, CliError> {
2125 AtlasStore::open_read_only_for_project(path, root).map_err(project_store_error)
2126}
2127
2128pub(crate) fn project_store_error(source: projectatlas_db::DbError) -> CliError {
2130 match source {
2131 projectatlas_db::DbError::ProjectRootMismatch { expected, found } => {
2132 CliError::ProjectMismatch(Box::new(IndexProjectMismatch {
2133 status: IndexReadStatus::ProjectMismatch,
2134 selected_project_root: expected,
2135 indexed_project_root: found,
2136 }))
2137 }
2138 other => CliError::Db(other),
2139 }
2140}
2141
2142pub(crate) fn init_config_path(root: &Path, explicit: Option<&Path>) -> PathBuf {
2144 if let Some(config_path) = explicit {
2145 return if config_path.is_absolute() {
2146 config_path.to_path_buf()
2147 } else {
2148 root.join(config_path)
2149 };
2150 }
2151 let nested_config = root.join(".projectatlas").join("config.toml");
2152 if nested_config.exists() {
2153 return nested_config;
2154 }
2155 let flat_config = root.join("projectatlas.toml");
2156 if flat_config.exists() {
2157 return flat_config;
2158 }
2159 nested_config
2160}
2161
2162pub(crate) fn run_init_bootstrap(
2164 root: &Path,
2165 db_path: &Path,
2166 config_path: Option<&Path>,
2167 options: &InitBootstrapOptions,
2168) -> Result<InitSetupReport, CliError> {
2169 let root = canonical_source_project_root(root)?;
2170 let project_dir = root.join(".projectatlas");
2171 let config_file = init_config_path(&root, config_path);
2172 let nonsource_file = project_dir.join("projectatlas-nonsource-files.toon");
2173 let project_dir_existed = project_dir.exists();
2174 let config_existed = config_file.exists();
2175 let nonsource_existed = nonsource_file.exists();
2176 let db_existed = db_path.exists();
2177
2178 init_project_with_config(&root, Some(&config_file))?;
2179 let mut store = open_atlas_store_for_project(db_path, &root)?;
2180
2181 let mut ok = true;
2182 let (scan_status, scan_report, scan_error) = if options.no_scan {
2183 (InitPhaseStatus::Skipped, None, None)
2184 } else {
2185 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
2186 let control = index_work_control(&symbol_options);
2187 match ScanRuntimePlan::for_path_controlled(
2188 config_path,
2189 &root,
2190 options.text_index_max_bytes,
2191 &control,
2192 )
2193 .and_then(|plan| run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control))
2194 {
2195 Ok(report) => (InitPhaseStatus::Verified, Some(report), None),
2196 Err(error) => {
2197 ok = false;
2198 (InitPhaseStatus::Failed, None, Some(error.to_string()))
2199 }
2200 }
2201 };
2202
2203 let purpose_query = HealthQuery {
2204 start_index: 0,
2205 limit: DEFAULT_HEALTH_LIMIT,
2206 category: None,
2207 severity: None,
2208 path_prefix: None,
2209 summary_only: false,
2210 scope: HealthScope::purpose_default(),
2211 };
2212 let purpose_queue = purpose_curation_page(&store, &purpose_query, "project-init")?;
2213 let next_steps = init_next_steps(options.no_scan, scan_error.is_some(), purpose_queue.total);
2214
2215 Ok(InitSetupReport {
2216 ok,
2217 root: normalize_native_path_display(&root),
2218 project_dir: InitPathStatus {
2219 status: init_path_status(project_dir_existed),
2220 path: normalize_native_path_display(project_dir),
2221 },
2222 config: InitPathStatus {
2223 status: init_path_status(config_existed),
2224 path: normalize_native_path_display(config_file),
2225 },
2226 nonsource_files: InitPathStatus {
2227 status: init_path_status(nonsource_existed),
2228 path: normalize_native_path_display(nonsource_file),
2229 },
2230 db: InitPathStatus {
2231 status: init_path_status(db_existed),
2232 path: normalize_native_path_display(db_path),
2233 },
2234 host_configs: Vec::new(),
2235 scan: InitScanPhase {
2236 status: scan_status,
2237 requested: !options.no_scan,
2238 force_rescan: options.force_rescan,
2239 report: scan_report,
2240 error: scan_error,
2241 },
2242 purpose_handoff: purpose_curator_handoff(purpose_queue),
2243 next_steps,
2244 })
2245}
2246
2247pub(crate) fn init_path_status(existed: bool) -> InitPhaseStatus {
2249 if existed {
2250 InitPhaseStatus::Exists
2251 } else {
2252 InitPhaseStatus::Created
2253 }
2254}
2255
2256fn purpose_handoff_instructions() -> Vec<String> {
2258 vec![
2259 "If the host supports isolated subagents, delegate this actionable low-scope batch at the lowest reasoning tier the host can enforce; otherwise let the main agent process the same bounded rows without blocking navigation.".to_string(),
2260 "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(),
2261 "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(),
2262 "Keep successful curator maintenance out of normal conversation; ProjectAtlas reports a handoff and never claims that the Rust server spawned an agent.".to_string(),
2263 ]
2264}
2265
2266pub(crate) fn purpose_curator_handoff(queue: PurposeCurationPage) -> PurposeCuratorHandoff {
2268 PurposeCuratorHandoff {
2269 agent_harness_expected: true,
2270 execution_owner: "agent_host",
2271 recommended_subagent_reasoning: "lowest_host_enforced",
2272 main_agent_fallback: true,
2273 server_started_curator: false,
2274 silent_on_success: true,
2275 queue,
2276 instructions: purpose_handoff_instructions(),
2277 }
2278}
2279
2280fn init_next_steps(
2282 scan_skipped: bool,
2283 scan_failed: bool,
2284 purpose_queue_total: usize,
2285) -> Vec<String> {
2286 let mut steps = Vec::new();
2287 if scan_skipped {
2288 steps.push("Run projectatlas scan when you are ready to build the deep index.".to_string());
2289 } else if scan_failed {
2290 steps.push(
2291 "Fix the scan/index error and rerun projectatlas init or projectatlas scan."
2292 .to_string(),
2293 );
2294 }
2295 if purpose_queue_total > 0 {
2296 steps.push(
2297 "Use the purpose_handoff queue to delegate purpose creation/correction at the lowest reasoning tier the host can enforce."
2298 .to_string(),
2299 );
2300 } else {
2301 steps.push("Purpose queue is empty for the default high-impact scope.".to_string());
2302 }
2303 steps.push("Run projectatlas overview to confirm repository orientation.".to_string());
2304 steps
2305}
2306
2307pub(crate) fn ensure_parent_dir(path: &Path) -> Result<(), CliError> {
2309 let Some(parent) = path.parent() else {
2310 return Ok(());
2311 };
2312 if parent.as_os_str().is_empty() {
2313 return Ok(());
2314 }
2315 fs::create_dir_all(parent).map_err(|source| CliError::Io {
2316 path: parent.to_path_buf(),
2317 source,
2318 })
2319}
2320
2321pub(crate) fn config_root_mismatch_error(
2323 config_path: &Path,
2324 config_root: &Path,
2325 selected_root: &Path,
2326) -> CliError {
2327 CliError::InvalidInput(format!(
2328 "ProjectAtlas config '{}' resolves project root '{}' outside selected project root '{}'",
2329 config_path.display(),
2330 config_root.display(),
2331 selected_root.display()
2332 ))
2333}
2334
2335pub(crate) fn default_mcp_project_root(
2337 db: &Path,
2338 config_path: Option<&Path>,
2339) -> Result<PathBuf, CliError> {
2340 if let Some(config_path) = config_path {
2341 let config = load_atlas_config(Some(config_path))?;
2342 let config_root = canonical_source_project_root(&config.root)?;
2343 if let Some(db_root) = project_root_from_db_path(db) {
2344 let db_root = canonical_source_project_root(&db_root)?;
2345 if config_root != db_root {
2346 return Err(config_root_mismatch_error(
2347 config_path,
2348 &config_root,
2349 &db_root,
2350 ));
2351 }
2352 }
2353 return Ok(config_root);
2354 }
2355 if db.exists()
2356 && let Some(project_root) = read_project_root_read_only(db)?
2357 {
2358 return canonical_source_project_root(Path::new(&project_root));
2359 }
2360 if let Some(project_root) = project_root_from_db_path(db) {
2361 return canonical_source_project_root(&project_root);
2362 }
2363 let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
2364 path: PathBuf::from("."),
2365 source,
2366 })?;
2367 canonical_source_project_root(¤t_dir)
2368}
2369
2370pub(crate) fn default_cli_project_root(
2372 db: &Path,
2373 config_path: Option<&Path>,
2374 database_path_is_explicit: bool,
2375) -> Result<PathBuf, CliError> {
2376 if !database_path_is_explicit
2377 && config_path.is_none()
2378 && let Some(project_root) = project_root_from_db_path(db)
2379 {
2380 return canonical_source_project_root(&project_root);
2381 }
2382 default_mcp_project_root(db, config_path)
2383}
2384
2385pub(crate) fn defaultable_cli_project_root(
2387 path: &Path,
2388 db: &Path,
2389 config_path: Option<&Path>,
2390 database_path_is_explicit: bool,
2391) -> Result<PathBuf, CliError> {
2392 if path == Path::new(".") {
2393 return default_cli_project_root(db, config_path, database_path_is_explicit);
2394 }
2395 Ok(path.to_path_buf())
2396}
2397
2398fn project_root_from_db_path(db: &Path) -> Option<PathBuf> {
2400 let parent = db.parent()?;
2401 let cache_dir_name = parent.file_name()?;
2402 if cache_dir_name != ".projectatlas" {
2403 return None;
2404 }
2405 parent
2406 .parent()
2407 .filter(|root| !root.as_os_str().is_empty())
2408 .map_or_else(|| Some(PathBuf::from(".")), |root| Some(root.to_path_buf()))
2409}
2410
2411pub(crate) fn scan_options_for_root(
2413 config_path: Option<&Path>,
2414 root: &Path,
2415) -> Result<ScanOptions, CliError> {
2416 Ok(load_scan_import_config(config_path, root)?
2417 .as_ref()
2418 .map_or_else(
2419 ScanOptions::default,
2420 atlas_map::AtlasMapConfig::scan_options,
2421 ))
2422}
2423
2424pub(crate) fn text_index_options(
2426 config: Option<&atlas_map::AtlasMapConfig>,
2427 max_bytes_override: Option<u64>,
2428) -> TextIndexOptions {
2429 let max_bytes = max_bytes_override
2430 .filter(|value| *value > 0)
2431 .or_else(|| config.map(atlas_map::AtlasMapConfig::text_index_max_bytes))
2432 .unwrap_or(atlas_map::DEFAULT_TEXT_INDEX_MAX_BYTES);
2433 TextIndexOptions::new(max_bytes)
2434}
2435
2436fn publication_base_generation(store: &AtlasStore) -> Result<IndexGeneration, CliError> {
2438 Ok(store
2439 .index_publication()?
2440 .map_or(IndexGeneration::ZERO, |publication| publication.generation))
2441}
2442
2443fn stage_full_index_publication(
2445 store: &AtlasStore,
2446 plan: &ScanRuntimePlan,
2447 symbol_options: &SymbolBuildOptions,
2448 reuse_unchanged_symbols: bool,
2449 import_legacy_purposes: bool,
2450 control: &IndexWorkControl,
2451) -> Result<IndexPublicationBatch, CliError> {
2452 let base_generation = publication_base_generation(store)?;
2453 let contract_fingerprint = plan.publication_contract_fingerprint();
2454 let previous_hashes = reuse_unchanged_symbols
2455 .then(|| indexed_file_hashes(store))
2456 .transpose()?;
2457 let nodes = scan_repo_controlled(
2458 &plan.root,
2459 &plan.scan_options,
2460 ScanLimits::default(),
2461 control,
2462 )
2463 .map_err(|source| source_inspection_error(&plan.root, source))?;
2464 control.check(IndexWorkStage::Publication)?;
2465 let purpose_import = import_legacy_purposes
2466 .then(|| plan.purpose_import_snapshot_controlled(&nodes, control))
2467 .transpose()?;
2468 let protected_purpose_paths = protected_purpose_paths(&nodes, purpose_import.as_ref());
2469 let text_paths = nodes
2470 .iter()
2471 .filter(|node| node.kind == NodeKind::File)
2472 .map(|node| node.path.clone())
2473 .collect::<Vec<_>>();
2474 let text = stage_text_index_for_changed_paths_controlled(
2475 &plan.root,
2476 &nodes,
2477 plan.text_options,
2478 control,
2479 )?;
2480 let retained_before_symbols =
2481 staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
2482 .saturating_add(staged_string_bytes(&text_paths))
2483 .saturating_add(staged_node_bytes(&nodes))
2484 .saturating_add(staged_text_bytes(&text))
2485 .saturating_add(staged_purpose_bytes(purpose_import.as_ref()));
2486 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
2487 let symbols = stage_symbols_for_nodes_with_limits(
2488 store,
2489 &plan.root,
2490 #[cfg(feature = "optional-parser-supervisor")]
2491 &plan.optional_parser_selection,
2492 &nodes,
2493 symbol_options,
2494 previous_hashes.as_ref(),
2495 None,
2496 &protected_purpose_paths,
2497 control,
2498 symbol_limits,
2499 )?;
2500 let graph = graph_projection::stage_full_repository_graph(
2501 store,
2502 &plan.root,
2503 base_generation,
2504 &nodes,
2505 &symbols,
2506 control,
2507 )?;
2508 let structural_summaries = stage_structural_summaries_for_nodes_controlled(
2509 store,
2510 &nodes,
2511 &text.rows,
2512 Some(&symbols),
2513 &protected_purpose_paths,
2514 symbol_options.effective_workers(),
2515 control,
2516 )?;
2517 enforce_publication_staging_budget(
2518 retained_before_symbols
2519 .saturating_add(symbols.retained_bytes)
2520 .saturating_add(graph.retained_bytes())
2521 .saturating_add(structural_summaries.retained_bytes),
2522 )?;
2523 Ok(IndexPublicationBatch {
2524 base_generation,
2525 contract_fingerprint,
2526 root: plan.root.clone(),
2527 nodes: NodePublicationBatch::Full { nodes },
2528 purpose_import,
2529 text_paths,
2530 text,
2531 symbols,
2532 graph,
2533 structural_summaries,
2534 })
2535}
2536
2537fn protected_purpose_paths(
2539 nodes: &[Node],
2540 purpose_import: Option<&PurposeImportSnapshot>,
2541) -> HashSet<String> {
2542 let indexed_paths = nodes
2543 .iter()
2544 .map(|node| node.path.as_str())
2545 .collect::<HashSet<_>>();
2546 let mut protected = BUILTIN_PROJECTATLAS_PURPOSES
2547 .iter()
2548 .filter(|(path, _purpose)| indexed_paths.contains(*path))
2549 .map(|(path, _purpose)| (*path).to_string())
2550 .collect::<HashSet<_>>();
2551 if let Some(snapshot) = purpose_import {
2552 protected.extend(
2553 snapshot
2554 .records
2555 .iter()
2556 .filter(|record| indexed_paths.contains(record.path.as_str()))
2557 .map(|record| record.path.clone()),
2558 );
2559 }
2560 protected
2561}
2562
2563fn staged_node_bytes(nodes: &[Node]) -> u64 {
2565 nodes.iter().fold(0_u64, |bytes, node| {
2566 bytes
2567 .saturating_add(node.path.len() as u64)
2568 .saturating_add(
2569 node.parent_path
2570 .as_ref()
2571 .map_or(0, |value| value.len() as u64),
2572 )
2573 .saturating_add(
2574 node.extension
2575 .as_ref()
2576 .map_or(0, |value| value.len() as u64),
2577 )
2578 .saturating_add(node.language.as_ref().map_or(0, |value| value.len() as u64))
2579 .saturating_add(
2580 node.content_hash
2581 .as_ref()
2582 .map_or(0, |value| value.len() as u64),
2583 )
2584 })
2585}
2586
2587fn staged_text_bytes(text: &TextIndexRefresh) -> u64 {
2589 text.rows.iter().fold(0_u64, |bytes, row| {
2590 let bytes = bytes.saturating_add(row.path.len() as u64);
2591 row.text.as_ref().map_or(bytes, |text| {
2592 bytes
2593 .saturating_add(text.path.len() as u64)
2594 .saturating_add(
2595 text.content_hash
2596 .as_ref()
2597 .map_or(0, |value| value.len() as u64),
2598 )
2599 .saturating_add(text.content.len() as u64)
2600 })
2601 })
2602}
2603
2604fn staged_purpose_bytes(purpose_import: Option<&PurposeImportSnapshot>) -> u64 {
2606 purpose_import.map_or(0, |snapshot| {
2607 snapshot
2608 .records
2609 .iter()
2610 .fold(snapshot.fingerprint.len() as u64, |bytes, record| {
2611 bytes
2612 .saturating_add(record.path.len() as u64)
2613 .saturating_add(record.summary.len() as u64)
2614 })
2615 })
2616}
2617
2618fn staged_string_bytes(values: &[String]) -> u64 {
2620 values.iter().fold(0_u64, |bytes, value| {
2621 bytes.saturating_add(value.len() as u64)
2622 })
2623}
2624
2625fn staged_publication_identity_bytes(root: &Path, contract_fingerprint: &str) -> u64 {
2627 (normalize_native_path_display(root).len() as u64)
2628 .saturating_add(contract_fingerprint.len() as u64)
2629}
2630
2631fn symbol_limits_with_remaining_staging_bytes(
2633 retained_bytes: u64,
2634) -> Result<SymbolPublicationLimits, CliError> {
2635 enforce_publication_staging_budget(retained_bytes)?;
2636 Ok(SymbolPublicationLimits {
2637 output_bytes: SymbolPublicationLimits::STANDARD
2638 .output_bytes
2639 .min(MAX_PUBLICATION_STAGING_BYTES.saturating_sub(retained_bytes)),
2640 ..SymbolPublicationLimits::STANDARD
2641 })
2642}
2643
2644fn enforce_publication_staging_budget(retained_bytes: u64) -> Result<(), CliError> {
2646 if retained_bytes > MAX_PUBLICATION_STAGING_BYTES {
2647 return Err(IndexWorkFailure::resource_limit(
2648 IndexWorkStage::Publication,
2649 IndexWorkResource::OutputBytes,
2650 MAX_PUBLICATION_STAGING_BYTES,
2651 retained_bytes,
2652 )
2653 .into());
2654 }
2655 Ok(())
2656}
2657
2658fn expected_nodes_after_incremental(
2660 baseline_nodes: Vec<Node>,
2661 changed_nodes: &[Node],
2662 absent_paths: &[String],
2663) -> Vec<Node> {
2664 let absent_paths = absent_paths
2665 .iter()
2666 .map(String::as_str)
2667 .filter(|path| !matches!(*path, "" | "."))
2668 .collect::<HashSet<_>>();
2669 let mut expected = baseline_nodes
2670 .into_iter()
2671 .filter(|node| !repository_path_is_absent(&node.path, &absent_paths))
2672 .map(|node| (node.path.clone(), node))
2673 .collect::<BTreeMap<_, _>>();
2674 for node in changed_nodes {
2675 expected.insert(node.path.clone(), node.clone());
2676 }
2677 expected.into_values().collect()
2678}
2679
2680fn repository_path_is_absent(path: &str, absent_paths: &HashSet<&str>) -> bool {
2682 absent_paths.contains(path)
2683 || path
2684 .match_indices('/')
2685 .any(|(separator, _)| absent_paths.contains(&path[..separator]))
2686}
2687
2688fn publish_index_batch(
2690 store: &mut AtlasStore,
2691 batch: IndexPublicationBatch,
2692 control: &IndexWorkControl,
2693) -> Result<IndexPublicationOutcome, CliError> {
2694 control.check(IndexWorkStage::Publication)?;
2695 let IndexPublicationBatch {
2696 base_generation,
2697 contract_fingerprint,
2698 root,
2699 nodes,
2700 purpose_import,
2701 text_paths,
2702 text,
2703 symbols,
2704 graph,
2705 structural_summaries,
2706 } = batch;
2707 let mut publication =
2708 store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
2709 publication.set_project_root(&root)?;
2710 let indexed_nodes = match nodes {
2711 NodePublicationBatch::Full { nodes } => {
2712 publication.begin_scan_replacement()?;
2713 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
2714 control.check(IndexWorkStage::Publication)?;
2715 publication.upsert_scan_node_batch(batch)?;
2716 }
2717 control.check(IndexWorkStage::Publication)?;
2718 publication.finish_scan_replacement()?;
2719 nodes
2720 }
2721 NodePublicationBatch::Incremental {
2722 nodes,
2723 absent_paths,
2724 expected_nodes: _,
2725 } => {
2726 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
2727 control.check(IndexWorkStage::Publication)?;
2728 publication.upsert_scan_node_batch(batch)?;
2729 }
2730 for batch in absent_paths.chunks(PUBLICATION_PATH_BATCH_SIZE) {
2731 control.check(IndexWorkStage::Publication)?;
2732 publication.mark_paths_absent(batch)?;
2733 }
2734 nodes
2735 }
2736 };
2737 seed_builtin_projectatlas_purposes(&publication, &indexed_nodes)?;
2738 apply_text_index_stage(&mut publication, &text_paths, &text, control)?;
2739 let purpose_import = purpose_import.map_or_else(
2740 || Ok(PurposeImportReport::default()),
2741 |snapshot| apply_purpose_import_snapshot(&publication, &indexed_nodes, &snapshot, control),
2742 )?;
2743 apply_symbol_build_stage(&mut publication, &symbols, control)?;
2744 graph.apply(&mut publication, control)?;
2745 apply_structural_summary_stage(&mut publication, &structural_summaries, control)?;
2746 complete_index_publication(publication, control)?;
2747 Ok(IndexPublicationOutcome {
2748 purpose_import,
2749 text_index: text.report,
2750 structural_summaries: structural_summaries.report,
2751 symbols: symbols.report,
2752 })
2753}
2754
2755fn apply_purpose_import_snapshot(
2757 store: &AtlasStore,
2758 nodes: &[Node],
2759 snapshot: &PurposeImportSnapshot,
2760 control: &IndexWorkControl,
2761) -> Result<PurposeImportReport, CliError> {
2762 let indexed_paths = nodes
2763 .iter()
2764 .map(|node| node.path.as_str())
2765 .collect::<HashSet<_>>();
2766 let mut report = PurposeImportReport::default();
2767 for record in &snapshot.records {
2768 control.check(IndexWorkStage::Publication)?;
2769 if !indexed_paths.contains(record.path.as_str()) {
2770 report.skipped_stale += 1;
2771 continue;
2772 }
2773 let Some(indexed) = store.load_node_by_path(&record.path)? else {
2774 report.skipped_stale += 1;
2775 continue;
2776 };
2777 if matches!(
2778 indexed.purpose.status,
2779 PurposeStatus::Approved | PurposeStatus::Stale
2780 ) {
2781 report.skipped_existing += 1;
2782 continue;
2783 }
2784 store.set_purpose(&record.path, &record.summary, PurposeSource::Imported)?;
2785 report.imported += 1;
2786 }
2787 Ok(report)
2788}
2789
2790#[cfg(test)]
2792pub(crate) fn run_scan_pipeline(
2793 store: &mut AtlasStore,
2794 plan: &ScanRuntimePlan,
2795 symbol_options: &SymbolBuildOptions,
2796) -> Result<ScanReport, CliError> {
2797 let control = index_work_control(symbol_options);
2798 run_scan_pipeline_controlled(store, plan, symbol_options, &control)
2799}
2800
2801pub(crate) fn run_scan_pipeline_controlled(
2803 store: &mut AtlasStore,
2804 plan: &ScanRuntimePlan,
2805 symbol_options: &SymbolBuildOptions,
2806 control: &IndexWorkControl,
2807) -> Result<ScanReport, CliError> {
2808 let bounded_control = bounded_index_work_control(control);
2809 let control = &bounded_control;
2810 let batch = stage_full_index_publication(store, plan, symbol_options, false, true, control)?;
2811 revalidate_staged_publication_inputs_with_purpose_snapshot(
2812 plan,
2813 batch.nodes.expected_nodes(),
2814 batch.purpose_import.as_ref(),
2815 control,
2816 )?;
2817 let outcome = publish_index_batch(store, batch, control)?;
2818 let overview = store.overview()?;
2819 Ok(ScanReport {
2820 overview,
2821 purpose_import: outcome.purpose_import,
2822 text_index: outcome.text_index,
2823 structural_summaries: outcome.structural_summaries,
2824 symbols: outcome.symbols,
2825 })
2826}
2827
2828#[cfg(test)]
2830pub(crate) fn run_symbol_build_pipeline(
2831 store: &mut AtlasStore,
2832 plan: &ScanRuntimePlan,
2833 symbol_options: &SymbolBuildOptions,
2834 previous_hashes: Option<&HashMap<String, String>>,
2835) -> Result<SymbolBuildReport, CliError> {
2836 let control = index_work_control(symbol_options);
2837 run_symbol_build_pipeline_controlled(store, plan, symbol_options, previous_hashes, &control)
2838}
2839
2840pub(crate) fn run_symbol_build_pipeline_controlled(
2842 store: &mut AtlasStore,
2843 plan: &ScanRuntimePlan,
2844 symbol_options: &SymbolBuildOptions,
2845 previous_hashes: Option<&HashMap<String, String>>,
2846 control: &IndexWorkControl,
2847) -> Result<SymbolBuildReport, CliError> {
2848 let bounded_control = bounded_index_work_control(control);
2849 let control = &bounded_control;
2850 control.check(IndexWorkStage::SymbolParsing)?;
2851 verify_index_project_root(store, &plan.root)?;
2852 verify_index_publication(store, plan)?;
2853 let base_generation = publication_base_generation(store)?;
2854 let nodes = store
2855 .load_nodes()?
2856 .into_iter()
2857 .map(|indexed| indexed.node)
2858 .collect::<Vec<_>>();
2859 let contract_fingerprint = plan.publication_contract_fingerprint();
2860 let retained_before_symbols =
2861 staged_publication_identity_bytes(&plan.root, &contract_fingerprint)
2862 .saturating_add(staged_node_bytes(&nodes));
2863 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
2864 let staged = stage_symbols_for_nodes_with_limits(
2865 store,
2866 &plan.root,
2867 #[cfg(feature = "optional-parser-supervisor")]
2868 &plan.optional_parser_selection,
2869 &nodes,
2870 symbol_options,
2871 previous_hashes,
2872 None,
2873 &HashSet::new(),
2874 control,
2875 symbol_limits,
2876 )?;
2877 let graph = graph_projection::stage_full_repository_graph(
2878 store,
2879 &plan.root,
2880 base_generation,
2881 &nodes,
2882 &staged,
2883 control,
2884 )?;
2885 enforce_publication_staging_budget(
2886 retained_before_symbols
2887 .saturating_add(staged.retained_bytes)
2888 .saturating_add(graph.retained_bytes()),
2889 )?;
2890 revalidate_staged_publication_inputs_controlled(plan, &nodes, None, control)?;
2891 control.check(IndexWorkStage::Publication)?;
2892 let mut publication =
2893 store.begin_index_projection_refresh_from(&contract_fingerprint, base_generation)?;
2894 apply_symbol_build_stage(&mut publication, &staged, control)?;
2895 graph.apply(&mut publication, control)?;
2896 complete_index_publication(publication, control)?;
2897 Ok(staged.report)
2898}
2899
2900#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2902pub(crate) struct UsageRuntimeInstance {
2903 id: UsageInstanceId,
2905 owner: UsageInstanceOwner,
2907}
2908
2909impl UsageRuntimeInstance {
2910 #[must_use]
2912 pub(crate) fn new(owner: UsageInstanceOwner) -> Option<Self> {
2913 let mut bytes = [0u8; 16];
2914 getrandom::fill(&mut bytes).ok()?;
2915 UsageInstanceId::from_bytes(bytes)
2916 .ok()
2917 .map(|id| Self { id, owner })
2918 }
2919
2920 fn record(
2922 self,
2923 store: &AtlasStore,
2924 event: &projectatlas_core::telemetry::UsageEvent,
2925 ) -> Result<(), CliError> {
2926 store.record_usage_for_instance(
2927 self.id,
2928 self.owner,
2929 event,
2930 matches!(self.owner, UsageInstanceOwner::CliInvocation),
2931 )?;
2932 Ok(())
2933 }
2934
2935 pub(crate) fn seal(self, store: &AtlasStore) -> Result<(), CliError> {
2937 store.seal_usage_instance(self.id)?;
2938 Ok(())
2939 }
2940}
2941
2942pub(crate) fn record_usage_estimate(
2944 store: &AtlasStore,
2945 usage_instance: Option<UsageRuntimeInstance>,
2946 session: &str,
2947 command: &str,
2948 path: Option<String>,
2949 query: Option<String>,
2950 estimated_without_projectatlas: usize,
2951 projectatlas_text: &str,
2952) -> Result<(), CliError> {
2953 record_usage_estimate_with_context(
2954 store,
2955 usage_instance,
2956 session,
2957 command,
2958 path,
2959 query,
2960 estimated_without_projectatlas,
2961 projectatlas_text,
2962 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
2963 TOKEN_BASELINE_SELECTED_CANDIDATES,
2964 TOKEN_CONFIDENCE_INFERRED,
2965 )
2966}
2967
2968#[allow(clippy::too_many_arguments)]
2970pub(crate) fn record_usage_estimate_with_context(
2971 store: &AtlasStore,
2972 usage_instance: Option<UsageRuntimeInstance>,
2973 session: &str,
2974 command: &str,
2975 path: Option<String>,
2976 query: Option<String>,
2977 estimated_without_projectatlas: usize,
2978 projectatlas_text: &str,
2979 token_savings_bucket: &str,
2980 baseline_kind: &str,
2981 confidence: &str,
2982) -> Result<(), CliError> {
2983 let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
2984 return Ok(());
2985 };
2986 store.finish_index_read_snapshot()?;
2987 usage_instance.record(
2988 store,
2989 &usage_from_estimates_with_context(
2990 session,
2991 command,
2992 path,
2993 query,
2994 estimated_without_projectatlas,
2995 estimate_tokens(projectatlas_text),
2996 token_savings_bucket,
2997 baseline_kind,
2998 confidence,
2999 ),
3000 )?;
3001 Ok(())
3002}
3003
3004pub(crate) fn record_directory_walk_usage_estimate(
3006 store: &AtlasStore,
3007 usage_instance: Option<UsageRuntimeInstance>,
3008 session: &str,
3009 command: &str,
3010 path: Option<String>,
3011 query: Option<String>,
3012 estimated_without_projectatlas: usize,
3013 projectatlas_text: &str,
3014) -> Result<(), CliError> {
3015 record_usage_estimate_with_context(
3016 store,
3017 usage_instance,
3018 session,
3019 command,
3020 path,
3021 query,
3022 estimated_without_projectatlas,
3023 projectatlas_text,
3024 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
3025 TOKEN_BASELINE_DIRECTORY_WALK,
3026 TOKEN_CONFIDENCE_POLICY_ESTIMATE,
3027 )
3028}
3029
3030pub(crate) fn record_usage_text(
3032 store: &AtlasStore,
3033 usage_instance: Option<UsageRuntimeInstance>,
3034 session: &str,
3035 command: &str,
3036 path: Option<String>,
3037 query: Option<String>,
3038 baseline_text: &str,
3039 projectatlas_text: &str,
3040) -> Result<(), CliError> {
3041 let Some(usage_instance) = usage_instance.filter(|_| !telemetry_disabled()) else {
3042 return Ok(());
3043 };
3044 store.finish_index_read_snapshot()?;
3045 usage_instance.record(
3046 store,
3047 &usage_from_text(
3048 session,
3049 command,
3050 path,
3051 query,
3052 baseline_text,
3053 projectatlas_text,
3054 ),
3055 )?;
3056 Ok(())
3057}
3058
3059pub(crate) fn telemetry_disabled() -> bool {
3061 truthy_env("PROJECTATLAS_NO_TELEMETRY")
3062}
3063
3064pub(crate) fn estimated_source_tokens_for_indexed_files(
3066 store: &AtlasStore,
3067 folder: Option<&str>,
3068 file_pattern: Option<&str>,
3069) -> Result<usize, CliError> {
3070 let matcher = FilePathMatcher::new(file_pattern)?;
3071 let mut total = 0usize;
3072 store.visit_file_token_estimates(folder, |path, size_bytes| {
3073 if matcher.is_match(&path) {
3074 total =
3075 total.saturating_add(estimated_source_tokens_for_file_metadata(&path, size_bytes));
3076 }
3077 Ok(true)
3078 })?;
3079 Ok(total)
3080}
3081
3082pub(crate) fn estimated_source_tokens_for_file_node(node: &Node) -> usize {
3084 estimated_source_tokens_for_file_metadata(&node.path, node.size_bytes)
3085}
3086
3087pub(crate) fn estimated_source_tokens_for_file_metadata(
3089 path: &str,
3090 size_bytes: Option<u64>,
3091) -> usize {
3092 size_bytes.map_or_else(|| estimate_tokens(path), byte_size_to_tokens)
3093}
3094
3095pub(crate) fn byte_size_to_tokens(bytes: u64) -> usize {
3097 let token_estimate = bytes.div_ceil(4);
3098 usize::try_from(token_estimate).unwrap_or(usize::MAX)
3099}
3100
3101pub(crate) fn byte_count_to_tokens(bytes: usize) -> usize {
3103 if bytes == 0 { 0 } else { bytes.div_ceil(4) }
3104}
3105
3106pub(crate) fn ranked_folder_nodes_with_reasons(
3108 store: &AtlasStore,
3109 query: &str,
3110 limit: usize,
3111) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3112 Ok(load_ranked_folder_nodes_with_reasons(store, query, limit)?)
3113}
3114
3115pub(crate) fn ranked_file_nodes_with_reasons(
3117 store: &AtlasStore,
3118 query: &str,
3119 folder: Option<&str>,
3120 file_pattern: Option<&str>,
3121 limit: usize,
3122 include_content: bool,
3123) -> Result<Vec<projectatlas_core::RankedNode>, CliError> {
3124 Ok(load_ranked_file_nodes_with_reasons(
3125 store,
3126 query,
3127 folder,
3128 file_pattern,
3129 limit,
3130 include_content,
3131 )?)
3132}
3133
3134pub(crate) fn next_step_report(
3136 store: &AtlasStore,
3137 query: &str,
3138 limit: Option<usize>,
3139) -> Result<NextStepReport, CliError> {
3140 Ok(build_next_report(store, query, limit)?)
3141}
3142
3143pub(crate) fn next_step_report_payload(report: &NextStepReport) -> Value {
3145 json!({
3146 "query": &report.query,
3147 "folders": render_ranked_node_rows("folders", &report.folders),
3148 "files": render_ranked_node_rows("files", &report.files),
3149 "suggestions": &report.suggestions,
3150 })
3151}
3152
3153#[derive(Debug, Serialize)]
3155#[allow(
3156 clippy::struct_excessive_bools,
3157 reason = "serialized queue paging and scope fields are independent wire facts"
3158)]
3159pub(crate) struct PurposeCurationPage {
3160 pub(crate) project_instance_id: String,
3162 pub(crate) active_generation: u64,
3164 pub(crate) task: String,
3166 pub(crate) work_key: String,
3168 pub(crate) actionable: bool,
3170 pub(crate) curation_scope: &'static str,
3172 pub(crate) total: usize,
3174 pub(crate) unfiltered_total: usize,
3176 pub(crate) returned: usize,
3178 pub(crate) start_index: usize,
3180 pub(crate) limit: usize,
3182 pub(crate) max_limit: usize,
3184 pub(crate) next_start_index: Option<usize>,
3186 pub(crate) truncated: bool,
3188 pub(crate) source_only: bool,
3190 pub(crate) folder_scope: String,
3192 pub(crate) file_scope: String,
3194 pub(crate) category: String,
3196 pub(crate) severity: String,
3198 pub(crate) path_prefix: String,
3200 pub(crate) summary_only: bool,
3202 pub(crate) items: Vec<PurposeCurationItem>,
3204}
3205
3206#[derive(Debug, Serialize)]
3208pub(crate) struct PurposeCurationItem {
3209 pub(crate) work_key: String,
3211 pub(crate) state_token: String,
3213 pub(crate) severity: String,
3215 pub(crate) id: String,
3217 pub(crate) category: String,
3219 pub(crate) path: String,
3221 pub(crate) related_path: String,
3223 pub(crate) kind: String,
3225 pub(crate) language: String,
3227 pub(crate) purpose: String,
3229 pub(crate) purpose_status: String,
3231 pub(crate) purpose_source: String,
3233 pub(crate) purpose_agent_reviewed: bool,
3235 pub(crate) review_priority: String,
3237 pub(crate) review_reason: String,
3239 pub(crate) content_summary: String,
3241 pub(crate) recommendation: String,
3243}
3244
3245#[derive(Clone, Debug, Deserialize, Serialize)]
3247pub(crate) struct PurposeReviewRequest {
3248 pub(crate) path: String,
3250 #[serde(default)]
3252 pub(crate) purpose: Option<String>,
3253 #[serde(default)]
3255 pub(crate) confirm_existing: bool,
3256 #[serde(default)]
3258 pub(crate) task: Option<String>,
3259 #[serde(default)]
3261 pub(crate) work_key: Option<String>,
3262 #[serde(default)]
3264 pub(crate) state_token: Option<String>,
3265}
3266
3267#[derive(Debug, Serialize)]
3269pub(crate) struct PurposeReviewReport {
3270 pub(crate) applied: bool,
3272 pub(crate) total: usize,
3274 pub(crate) changed: usize,
3276 pub(crate) skipped: usize,
3278 pub(crate) conflicts: usize,
3280 pub(crate) failed: usize,
3282 pub(crate) items: Vec<PurposeReviewItem>,
3284}
3285
3286#[derive(Debug, Serialize)]
3288pub(crate) struct PurposeReviewItem {
3289 pub(crate) path: String,
3291 pub(crate) action: PurposeReviewAction,
3293 pub(crate) current_status: String,
3295 pub(crate) current_source: String,
3297 pub(crate) purpose: String,
3299 pub(crate) error: String,
3301}
3302
3303#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
3305#[serde(rename_all = "kebab-case")]
3306pub(crate) enum PurposeReviewAction {
3307 Error,
3309 Skip,
3311 Review,
3313 WouldReview,
3315 Stale,
3317 Accepted,
3319 Unavailable,
3321}
3322
3323pub(crate) fn review_purposes(
3325 store: &AtlasStore,
3326 requests: &[PurposeReviewRequest],
3327 apply: bool,
3328) -> Result<PurposeReviewReport, CliError> {
3329 validate_purpose_review_admission(requests)?;
3330 let has_conditional_fields = requests.iter().any(has_conditional_purpose_review_field);
3331 if apply
3332 && has_conditional_fields
3333 && !requests.iter().all(is_complete_conditional_purpose_review)
3334 {
3335 return Err(CliError::InvalidInput(
3336 "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"
3337 .to_string(),
3338 ));
3339 }
3340
3341 if apply && !has_conditional_fields {
3345 let preview = collect_purpose_reviews(store, requests, false)?;
3346 validate_purpose_review_report(&preview)?;
3347 }
3348
3349 let report = if apply && has_conditional_fields {
3350 apply_conditional_purpose_reviews(store, requests)?
3351 } else {
3352 collect_purpose_reviews(store, requests, apply)?
3353 };
3354 validate_purpose_review_report(&report)?;
3355 Ok(report)
3356}
3357
3358pub(crate) fn validate_purpose_review_admission(
3360 requests: &[PurposeReviewRequest],
3361) -> Result<(), CliError> {
3362 if requests.is_empty() {
3363 return Err(CliError::InvalidInput(
3364 "purpose review input must contain at least one item".to_string(),
3365 ));
3366 }
3367 if requests.len() > MAX_PURPOSE_CURATION_BATCH_ROWS {
3368 return Err(CliError::InvalidInput(format!(
3369 "purpose review input contains {} items; maximum is {}",
3370 requests.len(),
3371 MAX_PURPOSE_CURATION_BATCH_ROWS
3372 )));
3373 }
3374
3375 let mut aggregate_bytes = 0usize;
3376 for (index, request) in requests.iter().enumerate() {
3377 validate_purpose_review_field(index, "path", &request.path, MAX_PURPOSE_REVIEW_PATH_BYTES)?;
3378 aggregate_bytes = aggregate_bytes
3379 .checked_add(request.path.len())
3380 .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
3381 for (name, value) in [
3382 ("purpose", request.purpose.as_deref()),
3383 ("task", request.task.as_deref()),
3384 ("work_key", request.work_key.as_deref()),
3385 ("state_token", request.state_token.as_deref()),
3386 ] {
3387 if let Some(value) = value {
3388 validate_purpose_review_field(index, name, value, MAX_PURPOSE_REVIEW_FIELD_BYTES)?;
3389 aggregate_bytes = aggregate_bytes
3390 .checked_add(value.len())
3391 .ok_or_else(|| purpose_review_input_too_large(usize::MAX))?;
3392 }
3393 }
3394 if aggregate_bytes > MAX_PURPOSE_REVIEW_INPUT_BYTES {
3395 return Err(purpose_review_input_too_large(aggregate_bytes));
3396 }
3397 }
3398 Ok(())
3399}
3400
3401fn validate_purpose_review_field(
3403 index: usize,
3404 name: &str,
3405 value: &str,
3406 maximum: usize,
3407) -> Result<(), CliError> {
3408 if value.len() > maximum {
3409 return Err(CliError::InvalidInput(format!(
3410 "purpose review item {index} field {name} contains {} bytes; maximum is {maximum}",
3411 value.len()
3412 )));
3413 }
3414 Ok(())
3415}
3416
3417fn purpose_review_input_too_large(actual: usize) -> CliError {
3419 CliError::InvalidInput(format!(
3420 "purpose review input contains {actual} aggregate string bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_BYTES}"
3421 ))
3422}
3423
3424fn collect_purpose_reviews(
3426 store: &AtlasStore,
3427 requests: &[PurposeReviewRequest],
3428 apply: bool,
3429) -> Result<PurposeReviewReport, CliError> {
3430 let mut items = Vec::with_capacity(requests.len());
3431 let mut retained_bytes = 0usize;
3432 for request in requests {
3433 let item = review_purpose_request(store, request, apply)?;
3434 retained_bytes = retained_bytes
3435 .checked_add(purpose_review_item_bytes(&item)?)
3436 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
3437 if retained_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
3438 return Err(purpose_review_report_too_large(retained_bytes));
3439 }
3440 items.push(item);
3441 }
3442 Ok(summarize_purpose_review(requests.len(), apply, items))
3443}
3444
3445fn purpose_review_item_bytes(item: &PurposeReviewItem) -> Result<usize, CliError> {
3447 let mut total = 0usize;
3448 for (name, value, maximum) in [
3449 ("path", item.path.as_str(), MAX_PURPOSE_REVIEW_PATH_BYTES),
3450 (
3451 "current_status",
3452 item.current_status.as_str(),
3453 MAX_PURPOSE_REVIEW_FIELD_BYTES,
3454 ),
3455 (
3456 "current_source",
3457 item.current_source.as_str(),
3458 MAX_PURPOSE_REVIEW_FIELD_BYTES,
3459 ),
3460 (
3461 "purpose",
3462 item.purpose.as_str(),
3463 MAX_PURPOSE_REVIEW_FIELD_BYTES,
3464 ),
3465 (
3466 PURPOSE_REVIEW_REPORT_ERROR_FIELD,
3467 item.error.as_str(),
3468 MAX_PURPOSE_REVIEW_FIELD_BYTES,
3469 ),
3470 ] {
3471 if value.len() > maximum {
3472 return Err(CliError::InvalidInput(format!(
3473 "purpose review report field {name} contains {} bytes; maximum is {maximum}",
3474 value.len()
3475 )));
3476 }
3477 total = total
3478 .checked_add(value.len())
3479 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
3480 }
3481 Ok(total)
3482}
3483
3484fn validate_purpose_review_report(report: &PurposeReviewReport) -> Result<(), CliError> {
3486 let json_bytes = serde_json::to_string_pretty(report)?
3487 .len()
3488 .checked_add(1)
3489 .ok_or_else(|| purpose_review_report_too_large(usize::MAX))?;
3490 if json_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
3491 return Err(purpose_review_report_too_large(json_bytes));
3492 }
3493 let toon_bytes = render_purpose_review_report(report).len();
3494 if toon_bytes > MAX_PURPOSE_REVIEW_REPORT_BYTES {
3495 return Err(purpose_review_report_too_large(toon_bytes));
3496 }
3497 Ok(())
3498}
3499
3500fn purpose_review_report_too_large(actual: usize) -> CliError {
3502 CliError::InvalidInput(format!(
3503 "purpose review report contains {actual} bytes; maximum is {MAX_PURPOSE_REVIEW_REPORT_BYTES}"
3504 ))
3505}
3506
3507fn apply_conditional_purpose_reviews(
3509 store: &AtlasStore,
3510 requests: &[PurposeReviewRequest],
3511) -> Result<PurposeReviewReport, CliError> {
3512 let prepared = requests
3513 .iter()
3514 .map(|request| {
3515 let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
3516 CliError::InvalidInput(format!(
3517 "invalid purpose review path {:?}: {source}",
3518 request.path
3519 ))
3520 })?;
3521 let purpose = request
3522 .purpose
3523 .as_deref()
3524 .map(str::trim)
3525 .filter(|value| !value.is_empty())
3526 .ok_or_else(|| {
3527 CliError::InvalidInput(
3528 "conditional purpose review requires an explicit reviewed purpose"
3529 .to_string(),
3530 )
3531 })?
3532 .to_string();
3533 let task = request.task.clone().ok_or_else(|| {
3534 CliError::InvalidInput(
3535 "conditional purpose review requires task, work_key, and state_token together"
3536 .to_string(),
3537 )
3538 })?;
3539 let work_key = request.work_key.clone().ok_or_else(|| {
3540 CliError::InvalidInput(
3541 "conditional purpose review requires task, work_key, and state_token together"
3542 .to_string(),
3543 )
3544 })?;
3545 let state_token = request.state_token.clone().ok_or_else(|| {
3546 CliError::InvalidInput(
3547 "conditional purpose review requires task, work_key, and state_token together"
3548 .to_string(),
3549 )
3550 })?;
3551 Ok((
3552 path.clone(),
3553 purpose.clone(),
3554 PurposeConditionalApplyRequest {
3555 task,
3556 path,
3557 work_key,
3558 state_token,
3559 purpose,
3560 },
3561 ))
3562 })
3563 .collect::<Result<Vec<_>, CliError>>()?;
3564 let database_requests = prepared
3565 .iter()
3566 .map(|(_, _, request)| request.clone())
3567 .collect::<Vec<_>>();
3568 let results = store.conditionally_set_purposes(&database_requests)?;
3569 let items = prepared
3570 .into_iter()
3571 .zip(results)
3572 .map(|((path, purpose, _), result)| {
3573 debug_assert_eq!(path, result.path);
3574 PurposeReviewItem {
3575 path,
3576 action: conditional_purpose_review_action(result.state, true),
3577 current_status: result
3578 .current_purpose
3579 .as_ref()
3580 .map(|purpose| purpose.status.to_string())
3581 .unwrap_or_default(),
3582 current_source: result
3583 .current_purpose
3584 .as_ref()
3585 .map(|purpose| purpose.source.to_string())
3586 .unwrap_or_default(),
3587 purpose,
3588 error: String::new(),
3589 }
3590 })
3591 .collect::<Vec<_>>();
3592 Ok(summarize_purpose_review(requests.len(), true, items))
3593}
3594
3595fn is_complete_conditional_purpose_review(request: &PurposeReviewRequest) -> bool {
3597 request.task.is_some()
3598 && request.work_key.is_some()
3599 && request.state_token.is_some()
3600 && request
3601 .purpose
3602 .as_deref()
3603 .is_some_and(|purpose| !purpose.trim().is_empty())
3604}
3605
3606fn has_conditional_purpose_review_field(request: &PurposeReviewRequest) -> bool {
3608 request.task.is_some() || request.work_key.is_some() || request.state_token.is_some()
3609}
3610
3611fn summarize_purpose_review(
3613 total: usize,
3614 applied: bool,
3615 items: Vec<PurposeReviewItem>,
3616) -> PurposeReviewReport {
3617 let changed = items
3618 .iter()
3619 .filter(|item| {
3620 matches!(
3621 item.action,
3622 PurposeReviewAction::Review | PurposeReviewAction::WouldReview
3623 )
3624 })
3625 .count();
3626 let skipped = items
3627 .iter()
3628 .filter(|item| {
3629 matches!(
3630 item.action,
3631 PurposeReviewAction::Skip | PurposeReviewAction::Accepted
3632 )
3633 })
3634 .count();
3635 let conflicts = items
3636 .iter()
3637 .filter(|item| {
3638 matches!(
3639 item.action,
3640 PurposeReviewAction::Stale
3641 | PurposeReviewAction::Accepted
3642 | PurposeReviewAction::Unavailable
3643 )
3644 })
3645 .count();
3646 let failed = items.iter().filter(|item| !item.error.is_empty()).count();
3647 PurposeReviewReport {
3648 applied,
3649 total,
3650 changed,
3651 skipped,
3652 conflicts,
3653 failed,
3654 items,
3655 }
3656}
3657
3658fn review_purpose_request(
3660 store: &AtlasStore,
3661 request: &PurposeReviewRequest,
3662 apply: bool,
3663) -> Result<PurposeReviewItem, CliError> {
3664 let path = validated_repo_node_key(Path::new(&request.path)).map_err(|source| {
3665 CliError::InvalidInput(format!(
3666 "invalid purpose review path {:?}: {source}",
3667 request.path
3668 ))
3669 })?;
3670 let conditional = match (
3671 request.task.as_deref(),
3672 request.work_key.as_deref(),
3673 request.state_token.as_deref(),
3674 ) {
3675 (Some(task), Some(work_key), Some(state_token)) => Some((task, work_key, state_token)),
3676 (None, None, None) => None,
3677 _ => {
3678 return Ok(PurposeReviewItem {
3679 path,
3680 action: PurposeReviewAction::Error,
3681 current_status: String::new(),
3682 current_source: String::new(),
3683 purpose: request.purpose.clone().unwrap_or_default(),
3684 error:
3685 "conditional purpose review requires task, work_key, and state_token together"
3686 .to_string(),
3687 });
3688 }
3689 };
3690 if let Some((task, work_key, state_token)) = conditional {
3691 let reviewed_purpose = request
3692 .purpose
3693 .as_deref()
3694 .map(str::trim)
3695 .filter(|value| !value.is_empty());
3696 let Some(reviewed_purpose) = reviewed_purpose else {
3697 return Ok(PurposeReviewItem {
3698 path,
3699 action: PurposeReviewAction::Error,
3700 current_status: String::new(),
3701 current_source: String::new(),
3702 purpose: String::new(),
3703 error: "conditional purpose review requires an explicit reviewed purpose"
3704 .to_string(),
3705 });
3706 };
3707 let state = if apply {
3708 store.conditionally_set_purpose(task, &path, work_key, state_token, reviewed_purpose)?
3709 } else {
3710 preview_conditional_purpose_review(store, task, &path, work_key, state_token)?
3711 };
3712 let current = store.load_node_by_path(&path)?;
3713 return Ok(PurposeReviewItem {
3714 path,
3715 action: conditional_purpose_review_action(state, apply),
3716 current_status: current
3717 .as_ref()
3718 .map(|node| node.purpose.status.to_string())
3719 .unwrap_or_default(),
3720 current_source: current
3721 .as_ref()
3722 .map(|node| node.purpose.source.to_string())
3723 .unwrap_or_default(),
3724 purpose: reviewed_purpose.to_string(),
3725 error: String::new(),
3726 });
3727 }
3728 let Some(indexed) = store.load_node_by_path(&path)? else {
3729 return Ok(PurposeReviewItem {
3730 path,
3731 action: PurposeReviewAction::Error,
3732 current_status: String::new(),
3733 current_source: String::new(),
3734 purpose: request.purpose.clone().unwrap_or_default(),
3735 error: "path is not indexed".to_string(),
3736 });
3737 };
3738 let current_status = indexed.purpose.status.to_string();
3739 let current_source = indexed.purpose.source.to_string();
3740 let current_purpose = indexed.purpose.purpose.clone().unwrap_or_default();
3741 let explicit_purpose = request
3742 .purpose
3743 .as_deref()
3744 .map(str::trim)
3745 .filter(|value| !value.is_empty());
3746 let Some(reviewed_purpose) = explicit_purpose.or_else(|| {
3747 request
3748 .confirm_existing
3749 .then_some(current_purpose.as_str())
3750 .filter(|value| !value.trim().is_empty())
3751 }) else {
3752 return Ok(PurposeReviewItem {
3753 path,
3754 action: PurposeReviewAction::Error,
3755 current_status,
3756 current_source,
3757 purpose: String::new(),
3758 error: "provide a reviewed purpose or set confirm_existing=true".to_string(),
3759 });
3760 };
3761
3762 if request.confirm_existing
3763 && explicit_purpose.is_none()
3764 && (indexed.purpose.status == PurposeStatus::Suggested
3765 || indexed.purpose.source == PurposeSource::Generated)
3766 {
3767 return Ok(PurposeReviewItem {
3768 path,
3769 action: PurposeReviewAction::Error,
3770 current_status,
3771 current_source,
3772 purpose: current_purpose,
3773 error: "generated suggestions require an explicit reviewed purpose".to_string(),
3774 });
3775 }
3776
3777 let reviewed_purpose = reviewed_purpose.trim().to_string();
3778 let action = if indexed.purpose.agent_reviewed() && current_purpose == reviewed_purpose {
3779 PurposeReviewAction::Skip
3780 } else if apply {
3781 store.set_purpose(&path, &reviewed_purpose, PurposeSource::Agent)?;
3782 PurposeReviewAction::Review
3783 } else {
3784 PurposeReviewAction::WouldReview
3785 };
3786 Ok(PurposeReviewItem {
3787 path,
3788 action,
3789 current_status,
3790 current_source,
3791 purpose: reviewed_purpose,
3792 error: String::new(),
3793 })
3794}
3795
3796fn preview_conditional_purpose_review(
3798 store: &AtlasStore,
3799 task: &str,
3800 path: &str,
3801 work_key: &str,
3802 state_token: &str,
3803) -> Result<PurposeConditionalApplyState, CliError> {
3804 let batch = store.load_purpose_curation_batch(task, &[path.to_string()])?;
3805 if let Some(candidate) = batch.items.first() {
3806 return Ok(
3807 if candidate.work_key == work_key && candidate.state_token == state_token {
3808 PurposeConditionalApplyState::Applied
3809 } else {
3810 PurposeConditionalApplyState::Stale
3811 },
3812 );
3813 }
3814 Ok(store
3815 .load_node_by_path(path)?
3816 .map_or(PurposeConditionalApplyState::PathUnavailable, |_node| {
3817 PurposeConditionalApplyState::Accepted
3818 }))
3819}
3820
3821const fn conditional_purpose_review_action(
3823 state: PurposeConditionalApplyState,
3824 apply: bool,
3825) -> PurposeReviewAction {
3826 match state {
3827 PurposeConditionalApplyState::Applied if apply => PurposeReviewAction::Review,
3828 PurposeConditionalApplyState::Applied => PurposeReviewAction::WouldReview,
3829 PurposeConditionalApplyState::Stale => PurposeReviewAction::Stale,
3830 PurposeConditionalApplyState::Accepted => PurposeReviewAction::Accepted,
3831 PurposeConditionalApplyState::PathUnavailable => PurposeReviewAction::Unavailable,
3832 }
3833}
3834
3835pub(crate) fn purpose_curation_page(
3837 store: &AtlasStore,
3838 query: &HealthQuery,
3839 task: &str,
3840) -> Result<PurposeCurationPage, CliError> {
3841 let page = store.purpose_curation_findings_page_current(query)?;
3842 let paths = page
3843 .findings
3844 .iter()
3845 .map(|finding| finding.path.clone())
3846 .collect::<Vec<_>>();
3847 let batch = store.load_purpose_curation_batch(task, &paths)?;
3848 let project_instance_id = batch.project_instance_id.to_string();
3849 let active_generation = batch.active_generation.get();
3850 let task = batch.task.clone();
3851 let work_key = batch.work_key.clone();
3852 let candidates = batch
3853 .items
3854 .into_iter()
3855 .map(|candidate| (candidate.node.node.path.clone(), candidate))
3856 .collect::<HashMap<_, _>>();
3857 let items = page
3858 .findings
3859 .iter()
3860 .filter_map(|finding| {
3861 let candidate = candidates.get(&finding.path)?;
3862 let node = &candidate.node;
3863 let review_signal = purpose_review_signal(&node.node, &node.purpose);
3864 Some(PurposeCurationItem {
3865 work_key: candidate.work_key.clone(),
3866 state_token: candidate.state_token.clone(),
3867 severity: health_severity_name(finding.severity).to_string(),
3868 id: finding.id.clone(),
3869 category: finding.category.clone(),
3870 path: finding.path.clone(),
3871 related_path: finding.related_path.clone().unwrap_or_default(),
3872 kind: node.node.kind.to_string(),
3873 language: node.node.language.clone().unwrap_or_default(),
3874 purpose: node.purpose.purpose.clone().unwrap_or_default(),
3875 purpose_status: node.purpose.status.to_string(),
3876 purpose_source: node.purpose.source.to_string(),
3877 purpose_agent_reviewed: node.purpose.agent_reviewed(),
3878 review_priority: review_signal.priority.to_string(),
3879 review_reason: review_signal.reason.to_string(),
3880 content_summary: node.summary.clone().unwrap_or_default(),
3881 recommendation: "Inspect bounded context, then use conditional purpose review with this task, work_key, and state_token."
3882 .to_string(),
3883 })
3884 })
3885 .collect::<Vec<_>>();
3886 let returned = items.len();
3887 Ok(PurposeCurationPage {
3888 project_instance_id,
3889 active_generation,
3890 task,
3891 work_key,
3892 actionable: returned > 0,
3893 curation_scope: purpose_queue_curation_scope(query),
3894 total: page.total,
3895 unfiltered_total: page.unfiltered_total,
3896 returned,
3897 start_index: page.start_index,
3898 limit: page.limit,
3899 max_limit: MAX_HEALTH_LIMIT,
3900 next_start_index: health_next_start_index(&page),
3901 truncated: health_next_start_index(&page).is_some(),
3902 source_only: query.scope.is_source_focused(),
3903 folder_scope: purpose_queue_folder_scope(query).to_string(),
3904 file_scope: purpose_queue_file_scope(query).to_string(),
3905 category: query.category.clone().unwrap_or_default(),
3906 severity: query.severity.map_or("", health_severity_name).to_string(),
3907 path_prefix: query.path_prefix.clone().unwrap_or_default(),
3908 summary_only: query.summary_only,
3909 items,
3910 })
3911}
3912
3913pub(crate) fn render_health_page(page: &HealthFindingsPage, query: &HealthQuery) -> String {
3915 let rows = page
3916 .findings
3917 .iter()
3918 .map(|finding| {
3919 json!({
3920 "severity": health_severity_name(finding.severity),
3921 "id": finding.id,
3922 "category": finding.category,
3923 "path": finding.path,
3924 "related_path": finding.related_path.as_deref().unwrap_or(""),
3925 "message": finding.message,
3926 "recommendation": finding.recommendation,
3927 })
3928 })
3929 .collect::<Vec<_>>();
3930 encode_agent_payload(&json!({
3931 "health": {
3932 "total": page.total,
3933 "unfiltered_total": page.unfiltered_total,
3934 "returned": page.returned,
3935 "start_index": page.start_index,
3936 "limit": page.limit,
3937 "max_limit": MAX_HEALTH_LIMIT,
3938 "next_start_index": health_next_start_index(page),
3939 "truncated": health_next_start_index(page).is_some(),
3940 "summary_only": query.summary_only,
3941 "source_only": query.scope.is_source_focused(),
3942 "category": query.category.as_deref().unwrap_or(""),
3943 "severity": query.severity.map_or("", health_severity_name),
3944 "path_prefix": query.path_prefix.as_deref().unwrap_or(""),
3945 },
3946 "health_findings": rows,
3947 }))
3948}
3949
3950pub(crate) fn render_coverage_report(report: &CoverageDiscoveryReport) -> String {
3952 encode_agent_payload(&json!({ "coverage": report }))
3953}
3954
3955pub(crate) fn render_purpose_curation_page(page: &PurposeCurationPage) -> String {
3957 encode_agent_payload(&json!({
3958 "purpose_curation": {
3959 "project_instance_id": page.project_instance_id,
3960 "active_generation": page.active_generation,
3961 "task": page.task,
3962 "work_key": page.work_key,
3963 "actionable": page.actionable,
3964 "curation_scope": page.curation_scope,
3965 "total": page.total,
3966 "unfiltered_total": page.unfiltered_total,
3967 "returned": page.returned,
3968 "start_index": page.start_index,
3969 "limit": page.limit,
3970 "max_limit": page.max_limit,
3971 "next_start_index": page.next_start_index,
3972 "truncated": page.truncated,
3973 "source_only": page.source_only,
3974 "folder_scope": page.folder_scope,
3975 "file_scope": page.file_scope,
3976 "category": page.category,
3977 "severity": page.severity,
3978 "path_prefix": page.path_prefix,
3979 "summary_only": page.summary_only,
3980 },
3981 "purpose_curation_items": page.items,
3982 }))
3983}
3984
3985pub(crate) fn render_purpose_review_report(report: &PurposeReviewReport) -> String {
3987 encode_agent_payload(&json!({
3988 "purpose_review": {
3989 "applied": report.applied,
3990 "total": report.total,
3991 "changed": report.changed,
3992 "skipped": report.skipped,
3993 "conflicts": report.conflicts,
3994 "failed": report.failed,
3995 },
3996 "purpose_review_items": report.items,
3997 }))
3998}
3999
4000fn purpose_queue_folder_scope(query: &HealthQuery) -> &'static str {
4002 match query.scope {
4003 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "source_relevant",
4004 _ => "all",
4005 }
4006}
4007
4008fn purpose_queue_file_scope(query: &HealthQuery) -> &'static str {
4010 match query.scope {
4011 HealthScope::PurposeDefault => "high_impact",
4012 HealthScope::PurposeWithAssets => "high_impact_and_assets",
4013 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "all_source",
4014 HealthScope::All | HealthScope::PurposeStrict => "all",
4015 }
4016}
4017
4018fn purpose_queue_curation_scope(query: &HealthQuery) -> &'static str {
4020 match query.scope {
4021 HealthScope::PurposeDefault => "low",
4022 HealthScope::PurposeWithAssets => "low_with_assets",
4023 HealthScope::SourceOnly | HealthScope::PurposeWithSourceFiles => "medium",
4024 HealthScope::All | HealthScope::PurposeStrict => "strict",
4025 }
4026}
4027
4028pub(crate) fn health_severity_name(severity: Severity) -> &'static str {
4030 severity.as_str()
4031}
4032
4033fn health_next_start_index(page: &HealthFindingsPage) -> Option<usize> {
4035 let page_width = page.limit.min(page.total.saturating_sub(page.start_index));
4036 let page_end = page.start_index.saturating_add(page_width);
4037 if page_width == 0 || page_end >= page.total {
4038 None
4039 } else {
4040 Some(page_end)
4041 }
4042}
4043
4044pub(crate) fn estimated_source_tokens_for_paths<'a>(
4046 store: &AtlasStore,
4047 paths: impl Iterator<Item = &'a str>,
4048) -> Result<usize, CliError> {
4049 let mut seen = HashSet::new();
4050 let mut total = 0usize;
4051 for path in paths {
4052 if seen.insert(path.to_string()) {
4053 total = total.saturating_add(estimated_source_tokens_for_path(store, path)?);
4054 }
4055 }
4056 Ok(total)
4057}
4058
4059pub(crate) fn estimated_source_tokens_for_path(
4061 store: &AtlasStore,
4062 path: &str,
4063) -> Result<usize, CliError> {
4064 if let Some(indexed) = store.load_node_by_path(path)?
4065 && indexed.node.kind == NodeKind::File
4066 {
4067 return Ok(estimated_source_tokens_for_file_node(&indexed.node));
4068 }
4069 Ok(read_indexed_file_content(store, path).map_or_else(
4070 |_| estimate_tokens(path),
4071 |content| estimate_tokens(&content),
4072 ))
4073}
4074
4075#[derive(Clone, Debug, Serialize)]
4077pub(crate) struct TextIndexReport {
4078 pub(crate) candidates: usize,
4080 pub(crate) indexed: usize,
4082 pub(crate) binary_or_non_utf8: usize,
4084 pub(crate) too_large: usize,
4086 pub(crate) skipped: usize,
4088 pub(crate) max_bytes: u64,
4090 pub(crate) bytes: usize,
4092}
4093
4094#[derive(Clone, Debug, Default, Serialize)]
4096pub(crate) struct StructuralSummaryReport {
4097 pub(crate) candidates: usize,
4099 pub(crate) summarized: usize,
4101 pub(crate) cleared: usize,
4103 pub(crate) too_large: usize,
4105 pub(crate) binary_or_non_utf8: usize,
4107 pub(crate) purpose_suggestions: usize,
4109}
4110
4111#[derive(Clone, Copy, Debug)]
4113pub(crate) struct TextIndexOptions {
4114 pub(crate) max_bytes: u64,
4116}
4117
4118impl TextIndexOptions {
4119 pub(crate) fn new(max_bytes: u64) -> Self {
4121 Self { max_bytes }
4122 }
4123}
4124
4125#[derive(Clone, Debug)]
4127pub(crate) struct TextIndexRow {
4128 path: String,
4130 text: Option<IndexedFileText>,
4132 reason: TextIndexSkipReason,
4134}
4135
4136pub(crate) struct TextIndexRefresh {
4138 pub(crate) report: TextIndexReport,
4140 pub(crate) rows: Vec<TextIndexRow>,
4142}
4143
4144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4146pub(crate) enum TextIndexSkipReason {
4147 Indexed,
4149 TooLarge,
4151 BinaryOrNonUtf8,
4153}
4154
4155#[derive(Debug, Serialize)]
4157pub(crate) struct SymbolBuildReport {
4158 pub(crate) candidates: usize,
4160 pub(crate) parsed: usize,
4162 pub(crate) unchanged: usize,
4164 pub(crate) too_large: usize,
4166 pub(crate) binary_or_non_utf8: usize,
4168 pub(crate) timed_out: usize,
4170 pub(crate) max_workers: usize,
4172 pub(crate) timeout_seconds: Option<u64>,
4174 pub(crate) symbols: usize,
4176 pub(crate) relations: usize,
4178 pub(crate) summaries: usize,
4180 pub(crate) purpose_suggestions: usize,
4182}
4183
4184struct IndexPublicationBatch {
4186 base_generation: IndexGeneration,
4188 contract_fingerprint: String,
4190 root: PathBuf,
4192 nodes: NodePublicationBatch,
4194 purpose_import: Option<PurposeImportSnapshot>,
4196 text_paths: Vec<String>,
4198 text: TextIndexRefresh,
4200 symbols: SymbolBuildStage,
4202 graph: graph_projection::StagedRepositoryGraph,
4204 structural_summaries: StructuralSummaryStage,
4206}
4207
4208enum NodePublicationBatch {
4210 Full {
4212 nodes: Vec<Node>,
4214 },
4215 Incremental {
4217 nodes: Vec<Node>,
4219 absent_paths: Vec<String>,
4221 expected_nodes: Vec<Node>,
4223 },
4224}
4225
4226impl NodePublicationBatch {
4227 fn expected_nodes(&self) -> &[Node] {
4229 match self {
4230 Self::Full { nodes } => nodes,
4231 Self::Incremental { expected_nodes, .. } => expected_nodes,
4232 }
4233 }
4234}
4235
4236struct IndexPublicationOutcome {
4238 purpose_import: PurposeImportReport,
4240 text_index: TextIndexReport,
4242 structural_summaries: StructuralSummaryReport,
4244 symbols: SymbolBuildReport,
4246}
4247
4248struct SymbolBuildStage {
4250 report: SymbolBuildReport,
4252 changes: Vec<SymbolProjectionChange>,
4254 retained_bytes: u64,
4256}
4257
4258enum SymbolProjectionChange {
4260 Parsed(SymbolParseSuccess),
4262 Clear {
4264 path: String,
4266 language: Option<String>,
4268 },
4269}
4270
4271struct StructuralSummaryStage {
4273 report: StructuralSummaryReport,
4275 changes: Vec<StructuralSummaryChange>,
4277 retained_bytes: u64,
4279}
4280
4281#[derive(Default)]
4283struct StructuralSummaryDerivation {
4284 change: Option<StructuralSummaryChange>,
4286 summarized: usize,
4288 cleared: usize,
4290 too_large: usize,
4292 binary_or_non_utf8: usize,
4294 purpose_suggestions: usize,
4296 retained_bytes: u64,
4298}
4299
4300enum StructuralSummaryChange {
4302 Set {
4304 path: String,
4306 summary: String,
4308 purpose_suggestion: Option<String>,
4310 },
4311 Clear {
4313 path: String,
4315 },
4316}
4317
4318#[derive(Debug, Serialize)]
4320pub(crate) struct WatchReport {
4321 pub(crate) mode: String,
4323 pub(crate) cycles: usize,
4325 pub(crate) once: bool,
4327 #[serde(skip_serializing_if = "Option::is_none")]
4329 pub(crate) fallback_reason: Option<String>,
4330 pub(crate) text_index: TextIndexReport,
4332 pub(crate) structural_summaries: StructuralSummaryReport,
4334 pub(crate) last_symbols: SymbolBuildReport,
4336}
4337
4338#[derive(Debug, Default)]
4340pub(crate) struct WatchChangeSet {
4341 requires_full_scan: bool,
4343 paths: HashSet<PathBuf>,
4345}
4346
4347impl WatchChangeSet {
4348 fn has_changes(&self) -> bool {
4350 self.requires_full_scan || !self.paths.is_empty()
4351 }
4352
4353 fn merge(&mut self, other: Self) {
4355 self.requires_full_scan |= other.requires_full_scan;
4356 self.paths.extend(other.paths);
4357 }
4358}
4359
4360#[derive(Debug, Serialize)]
4362pub(crate) struct LegacyPurposeReport {
4363 pub(crate) applied: bool,
4365 pub(crate) purpose_files_found: usize,
4367 pub(crate) purpose_files_removed: usize,
4369 pub(crate) source_header_candidates: Vec<String>,
4371 pub(crate) purpose_files: Vec<String>,
4373}
4374
4375#[derive(Debug, Serialize)]
4377pub(crate) struct SettingsReport {
4378 pub(crate) cache_dir: PathStatus,
4380 pub(crate) db: PathStatus,
4382 pub(crate) db_wal: PathStatus,
4384 pub(crate) db_shm: PathStatus,
4386 pub(crate) db_journal: PathStatus,
4388 pub(crate) mcp_config: PathStatus,
4390 pub(crate) config_path: Option<String>,
4392 pub(crate) repo_root: String,
4394 pub(crate) root_detection_source: String,
4396 pub(crate) root_verified: bool,
4398 pub(crate) root_mismatches: Vec<String>,
4400 pub(crate) map_path: String,
4402 pub(crate) nonsource_files_path: String,
4404 pub(crate) default_format: String,
4406 pub(crate) default_search_case_sensitive: bool,
4408 pub(crate) search_source: String,
4410 pub(crate) text_index_max_bytes: u64,
4412 pub(crate) watcher: WatchStatusReport,
4414 pub(crate) index: Option<SettingsIndexStats>,
4416 pub(crate) telemetry: Option<TelemetryRetentionState>,
4418 pub(crate) database: DatabaseSettingsReport,
4420 pub(crate) language_registry: LanguageRegistryReport,
4422 pub(crate) semantic_relation_contract_digest: String,
4424 pub(crate) relation_family_inventory: RelationFamilyInventoryReport,
4426 pub(crate) search: SettingsSearchReport,
4428 pub(crate) optional_parser_pack: OptionalParserSettingsReport,
4430}
4431
4432#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
4434#[serde(rename_all = "snake_case")]
4435pub(crate) enum SettingsCapabilityState {
4436 Ready,
4438 Empty,
4440 Unavailable,
4442}
4443
4444#[derive(Debug, Serialize)]
4446pub(crate) struct SettingsSearchModeReport {
4447 pub(crate) state: SettingsCapabilityState,
4449}
4450
4451#[derive(Debug, Serialize)]
4453pub(crate) struct SettingsLexicalSearchReport {
4454 pub(crate) state: SettingsCapabilityState,
4456 pub(crate) source: &'static str,
4458 pub(crate) exact_verification: bool,
4460}
4461
4462#[derive(Debug, Serialize)]
4464pub(crate) struct SettingsSearchReport {
4465 pub(crate) default_mode: &'static str,
4467 pub(crate) lexical: SettingsLexicalSearchReport,
4469 pub(crate) fts: SettingsSearchModeReport,
4471 pub(crate) semantic: SettingsSearchModeReport,
4473 pub(crate) hybrid: SettingsSearchModeReport,
4475}
4476
4477#[derive(Debug, Serialize)]
4479pub(crate) struct OptionalParserSettingsReport {
4480 pub(crate) compiled: bool,
4482 #[cfg(feature = "optional-parser-supervisor")]
4484 pub(crate) lifecycle: OptionalParserPackLifecycleReport,
4485 #[cfg(not(feature = "optional-parser-supervisor"))]
4487 pub(crate) state: &'static str,
4488}
4489
4490#[derive(Debug, Serialize)]
4492pub(crate) struct PathStatus {
4493 pub(crate) path: String,
4495 pub(crate) exists: bool,
4497 pub(crate) size_bytes: Option<u64>,
4499}
4500
4501#[derive(Debug, Serialize)]
4503pub(crate) struct SettingsIndexStats {
4504 pub(crate) project_root: Option<String>,
4506 pub(crate) files: usize,
4508 pub(crate) folders: usize,
4510 pub(crate) missing_purposes: usize,
4512 pub(crate) stale_purposes: usize,
4514 pub(crate) suggested_purposes: usize,
4516 pub(crate) indexed_text_files: usize,
4518 pub(crate) indexed_text_bytes: usize,
4520 pub(crate) symbols: usize,
4522 pub(crate) relations: usize,
4524 pub(crate) token_calls: usize,
4526 pub(crate) health_findings: usize,
4528}
4529
4530#[derive(Debug, Serialize)]
4532pub(crate) struct WatchStatusReport {
4533 pub(crate) available: bool,
4535 pub(crate) active: bool,
4537 pub(crate) mode: String,
4539 pub(crate) event_backend_available: bool,
4541 pub(crate) recommendation: String,
4543}
4544
4545#[derive(Debug, Serialize)]
4547pub(crate) struct ResetIndexReport {
4548 pub(crate) applied: bool,
4550 pub(crate) dry_run: bool,
4552 files: Vec<PathStatus>,
4554 pub(crate) removed: usize,
4556}
4557
4558pub(crate) fn build_settings_report(
4560 db: &Path,
4561 config_path: Option<&Path>,
4562 format: OutputFormat,
4563) -> Result<SettingsReport, CliError> {
4564 let absolute_db = absolute_path(db)?;
4565 let resolved_config = resolved_mcp_config_path(&absolute_db, config_path)?;
4566 let config = if let Some(config_path) = resolved_config.as_deref() {
4567 load_atlas_config(Some(config_path))?
4568 } else {
4569 let project_root = default_mcp_project_root(&absolute_db, None)?;
4570 load_atlas_config_for_root(&project_root)?
4571 };
4572 let cache_dir = absolute_db
4573 .parent()
4574 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
4575 let database = database_settings_report(&absolute_db)?;
4576 let (index, telemetry, file_text_fts) =
4577 if database.schema.compatibility == DatabaseSchemaCompatibility::Current {
4578 let store = AtlasStore::open_read_only(&absolute_db)?;
4579 let snapshot_publication = store.index_publication()?;
4580 if settings_publication_matches(
4581 database.publication.as_ref(),
4582 snapshot_publication.as_ref(),
4583 ) {
4584 (
4585 Some(settings_index_stats(&store)?),
4586 Some(store.telemetry_retention_state()?),
4587 Some(store.file_text_fts_state()?),
4588 )
4589 } else {
4590 (None, None, None)
4591 }
4592 } else {
4593 (None, None, None)
4594 };
4595 let repo_root = normalize_display_path(&config.root);
4596 let db_project_root = index
4597 .as_ref()
4598 .and_then(|stats| stats.project_root.as_ref())
4599 .cloned();
4600 let mut root_mismatches = Vec::new();
4601 if let Some(db_root) = db_project_root.as_ref()
4602 && db_root != &repo_root
4603 {
4604 root_mismatches.push(format!(
4605 "db root {db_root:?} does not match config root {repo_root:?}"
4606 ));
4607 }
4608 let root_detection_source = if resolved_config.is_some() {
4609 "config"
4610 } else if db_project_root.is_some() {
4611 "db"
4612 } else {
4613 "db-path-or-cwd"
4614 }
4615 .to_string();
4616 let lexical_publication_ready = database.publication.as_ref().is_some_and(|publication| {
4617 publication.state == IndexPublicationState::Complete
4618 && publication.generation != IndexGeneration::ZERO
4619 && publication.contract_fingerprint_state == DatabasePublicationContractState::Valid
4620 });
4621 let lexical_state = match (lexical_publication_ready, index.as_ref()) {
4622 (true, Some(stats)) if stats.indexed_text_files == 0 => SettingsCapabilityState::Empty,
4623 (true, Some(_)) => SettingsCapabilityState::Ready,
4624 _ => SettingsCapabilityState::Unavailable,
4625 };
4626 let fts_state = match (lexical_publication_ready, file_text_fts.as_ref()) {
4627 (true, Some(state)) if !state.synchronized => SettingsCapabilityState::Unavailable,
4628 (true, Some(state)) if state.source_rows == 0 => SettingsCapabilityState::Empty,
4629 (true, Some(_)) => SettingsCapabilityState::Ready,
4630 _ => SettingsCapabilityState::Unavailable,
4631 };
4632 let search = SettingsSearchReport {
4633 default_mode: "lexical",
4634 lexical: SettingsLexicalSearchReport {
4635 state: lexical_state,
4636 source: "persisted_text",
4637 exact_verification: true,
4638 },
4639 fts: SettingsSearchModeReport { state: fts_state },
4640 semantic: SettingsSearchModeReport {
4641 state: SettingsCapabilityState::Unavailable,
4642 },
4643 hybrid: SettingsSearchModeReport {
4644 state: SettingsCapabilityState::Unavailable,
4645 },
4646 };
4647 #[cfg(feature = "optional-parser-supervisor")]
4648 let optional_parser_pack = OptionalParserSettingsReport {
4649 compiled: true,
4650 lifecycle: OptionalParserPackLifecycle::new(&config.root, None)?.status()?,
4651 };
4652 #[cfg(not(feature = "optional-parser-supervisor"))]
4653 let optional_parser_pack = OptionalParserSettingsReport {
4654 compiled: false,
4655 state: "compiled_unavailable",
4656 };
4657 Ok(SettingsReport {
4658 cache_dir: path_status(&cache_dir)?,
4659 db: path_status(&absolute_db)?,
4660 db_wal: path_status(&db_sidecar_path(&absolute_db, "wal"))?,
4661 db_shm: path_status(&db_sidecar_path(&absolute_db, "shm"))?,
4662 db_journal: path_status(&db_sidecar_path(&absolute_db, "journal"))?,
4663 mcp_config: path_status(&mcp_config_path_for_db(&absolute_db))?,
4664 config_path: resolved_config.map(|path| normalize_display_path(&path)),
4665 repo_root,
4666 root_detection_source,
4667 root_verified: root_mismatches.is_empty(),
4668 root_mismatches,
4669 map_path: normalize_display_path(&config.map_path),
4670 nonsource_files_path: normalize_display_path(&config.nonsource_files_path),
4671 default_format: format!("{format:?}").to_ascii_lowercase(),
4672 default_search_case_sensitive: false,
4673 search_source: "sqlite-file-text".to_string(),
4674 text_index_max_bytes: config.text_index_max_bytes(),
4675 watcher: watcher_status_report(false),
4676 index,
4677 telemetry,
4678 database,
4679 language_registry: language_registry_report(),
4680 semantic_relation_contract_digest: semantic_resolution_contract_digest(),
4681 relation_family_inventory: relation_family_inventory_report(),
4682 search,
4683 optional_parser_pack,
4684 })
4685}
4686
4687fn settings_publication_matches(
4689 diagnostic: Option<&DatabasePublicationReport>,
4690 snapshot: Option<&IndexPublication>,
4691) -> bool {
4692 match (diagnostic, snapshot) {
4693 (None, None) => true,
4694 (Some(diagnostic), Some(snapshot))
4695 if diagnostic.state == snapshot.state
4696 && diagnostic.generation == snapshot.generation =>
4697 {
4698 match diagnostic.contract_fingerprint_state {
4699 DatabasePublicationContractState::Missing => {
4700 snapshot.contract_fingerprint.is_none()
4701 }
4702 DatabasePublicationContractState::Valid => {
4703 diagnostic.contract_fingerprint.as_deref()
4704 == snapshot.contract_fingerprint.as_deref()
4705 }
4706 DatabasePublicationContractState::Invalid => false,
4707 }
4708 }
4709 _ => false,
4710 }
4711}
4712
4713pub(crate) fn settings_index_stats(store: &AtlasStore) -> Result<SettingsIndexStats, CliError> {
4715 let overview = store.overview()?;
4716 let health_findings = store.unresolved_health_finding_count_current()?;
4717 Ok(SettingsIndexStats {
4718 project_root: store
4719 .project_root()?
4720 .map(|path| normalize_native_path_display_str(&path)),
4721 files: overview.files,
4722 folders: overview.folders,
4723 missing_purposes: overview.missing_purposes,
4724 stale_purposes: overview.stale_purposes,
4725 suggested_purposes: overview.suggested_purposes,
4726 indexed_text_files: store.file_text_count()?,
4727 indexed_text_bytes: store.file_text_byte_count()?,
4728 symbols: store.symbol_count()?,
4729 relations: store.symbol_relation_count()?,
4730 token_calls: store.token_overview(None)?.calls,
4731 health_findings,
4732 })
4733}
4734
4735pub(crate) fn reset_index_files(
4737 db: &Path,
4738 apply: bool,
4739 dry_run: bool,
4740 include_mcp_config: bool,
4741) -> Result<ResetIndexReport, CliError> {
4742 let absolute_db = absolute_path(db)?;
4743 let mut targets = vec![
4744 absolute_db.clone(),
4745 db_sidecar_path(&absolute_db, "wal"),
4746 db_sidecar_path(&absolute_db, "shm"),
4747 db_sidecar_path(&absolute_db, "journal"),
4748 ];
4749 if include_mcp_config {
4750 targets.push(mcp_config_path_for_db(&absolute_db));
4751 }
4752 targets.sort();
4753 targets.dedup();
4754 let files = targets
4755 .iter()
4756 .map(|path| path_status(path))
4757 .collect::<Result<Vec<_>, _>>()?;
4758 let should_apply = apply && !dry_run;
4759 let mut removed = 0;
4760 if should_apply {
4761 for target in &targets {
4762 if target.is_file() {
4763 fs::remove_file(target).map_err(|source| CliError::Io {
4764 path: target.clone(),
4765 source,
4766 })?;
4767 removed += 1;
4768 }
4769 }
4770 }
4771 Ok(ResetIndexReport {
4772 applied: should_apply,
4773 dry_run: !should_apply,
4774 files,
4775 removed,
4776 })
4777}
4778
4779pub(crate) fn resolved_mcp_config_path(
4781 db: &Path,
4782 config: Option<&Path>,
4783) -> Result<Option<PathBuf>, CliError> {
4784 if let Some(path) = config {
4785 return Ok(Some(absolute_path(path)?));
4786 }
4787 let mut candidate_roots = Vec::new();
4788 if db.exists()
4789 && let Some(project_root) = read_project_root_read_only(db)?
4790 {
4791 candidate_roots.push(PathBuf::from(project_root));
4792 }
4793 let absolute_db = absolute_path(db)?;
4794 if let Some(project_root) = project_root_from_db_path(&absolute_db) {
4795 candidate_roots.push(project_root);
4796 }
4797 for root in candidate_roots {
4798 for candidate in config_candidates_for_root(&root) {
4799 if candidate.exists() {
4800 return Ok(Some(absolute_path(&candidate)?));
4801 }
4802 }
4803 }
4804 Ok(None)
4805}
4806
4807fn config_candidates_for_root(root: &Path) -> [PathBuf; 2] {
4809 [
4810 root.join(".projectatlas").join("config.toml"),
4811 root.join("projectatlas.toml"),
4812 ]
4813}
4814
4815pub(crate) fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
4817 if path.is_absolute() {
4818 return Ok(path.to_path_buf());
4819 }
4820 let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
4821 path: PathBuf::from("."),
4822 source,
4823 })?;
4824 Ok(current_dir.join(path))
4825}
4826
4827pub(crate) fn path_status(path: &Path) -> Result<PathStatus, CliError> {
4829 let absolute = absolute_path(path)?;
4830 let metadata = fs::metadata(&absolute).ok();
4831 Ok(PathStatus {
4832 path: normalize_display_path(&absolute),
4833 exists: metadata.is_some(),
4834 size_bytes: metadata
4835 .as_ref()
4836 .and_then(|metadata| metadata.is_file().then_some(metadata.len())),
4837 })
4838}
4839
4840pub(crate) fn db_sidecar_path(db: &Path, suffix: &str) -> PathBuf {
4842 PathBuf::from(format!("{}-{suffix}", db.display()))
4843}
4844
4845pub(crate) fn mcp_config_path_for_db(db: &Path) -> PathBuf {
4847 db.parent().map_or_else(
4848 || PathBuf::from("projectatlas.mcp.json"),
4849 |parent| parent.join("projectatlas.mcp.json"),
4850 )
4851}
4852
4853pub(crate) fn normalize_display_path(path: &Path) -> String {
4855 normalize_native_path_display(path)
4856}
4857
4858pub(crate) fn watcher_status_report(active: bool) -> WatchStatusReport {
4860 let notify_available = notify_runtime_available();
4861 let mode = if notify_available {
4862 WATCH_MODE_NOTIFY
4863 } else {
4864 WATCH_MODE_POLLING
4865 };
4866 let recommendation = if notify_available {
4867 "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for event-backed refresh with portable polling fallback."
4868 } else {
4869 "Run `projectatlas watch --once` for one refresh or `projectatlas watch` for portable polling refresh."
4870 };
4871 WatchStatusReport {
4872 available: true,
4873 active,
4874 mode: mode.to_string(),
4875 event_backend_available: notify_available,
4876 recommendation: recommendation.to_string(),
4877 }
4878}
4879
4880pub(crate) fn lint_database_if_present(
4882 db: &Path,
4883 root: &Path,
4884 config_path: Option<&Path>,
4885 purpose_level: PurposeLintLevel,
4886) -> Result<(String, i32), CliError> {
4887 match db.try_exists() {
4888 Ok(false) => return Ok((String::new(), 0)),
4889 Ok(true) => {}
4890 Err(source) => {
4891 return Err(CliError::Io {
4892 path: db.to_path_buf(),
4893 source,
4894 });
4895 }
4896 }
4897 let store = open_fresh_atlas_store_for_project(db, root, config_path)?;
4898 let query = purpose_level.health_query();
4899 let page = store.unresolved_health_findings_page_current(&query)?;
4900 let blocking = page
4901 .findings
4902 .iter()
4903 .filter(|finding| purpose_level.blocks_category(finding.category.as_str()))
4904 .collect::<Vec<_>>();
4905 if blocking.is_empty() {
4906 return Ok((String::new(), 0));
4907 }
4908 let mut report = format!(
4909 "ProjectAtlas SQLite index health findings (purpose-level {}, showing {} of {}):\n",
4910 purpose_level.as_str(),
4911 blocking.len(),
4912 page.total
4913 );
4914 for finding in blocking {
4915 writeln!(
4916 &mut report,
4917 "- [{}] {}: {}",
4918 finding.category, finding.path, finding.recommendation
4919 )
4920 .map_err(|source| CliError::Output(io::Error::other(source.to_string())))?;
4921 }
4922 Ok((report, 1))
4923}
4924
4925#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4927pub(crate) enum PurposeLintLevel {
4928 Low,
4930 Medium,
4932 Strict,
4934}
4935
4936impl PurposeLintLevel {
4937 pub(crate) fn as_str(self) -> &'static str {
4939 match self {
4940 Self::Low => "low",
4941 Self::Medium => "medium",
4942 Self::Strict => "strict",
4943 }
4944 }
4945
4946 fn health_query(self) -> HealthQuery {
4948 let scope = match self {
4949 Self::Low => HealthScope::purpose_default(),
4950 Self::Medium => HealthScope::purpose_with_source_files(),
4951 Self::Strict => HealthScope::purpose_strict(),
4952 };
4953 HealthQuery {
4954 start_index: 0,
4955 limit: MAX_HEALTH_LIMIT,
4956 category: None,
4957 severity: Some(Severity::Warning),
4958 path_prefix: None,
4959 summary_only: false,
4960 scope,
4961 }
4962 }
4963
4964 fn blocks_category(self, category: &str) -> bool {
4966 match category {
4967 CATEGORY_STALE_PURPOSE
4968 | CATEGORY_DUPLICATE_PURPOSE
4969 | CATEGORY_REPEATED_TEMPORARY_FOLDER => true,
4970 CATEGORY_MISSING_PURPOSE
4971 | CATEGORY_SUGGESTED_PURPOSE_REVIEW
4972 | CATEGORY_PURPOSE_AGENT_REVIEW_REQUIRED => self != Self::Low,
4973 _ => false,
4974 }
4975 }
4976}
4977
4978pub(crate) fn notify_runtime_available() -> bool {
4980 let (sender, _receiver) = mpsc::channel();
4981 RecommendedWatcher::new(
4982 move |result: notify::Result<Event>| {
4983 if sender.send(result).is_err() {
4984 }
4986 },
4987 Config::default(),
4988 )
4989 .is_ok()
4990}
4991
4992#[derive(Clone, Copy, Debug)]
4994pub(crate) struct SymbolBuildOptions {
4995 pub(crate) max_bytes: u64,
4997 max_workers: Option<usize>,
4999 timeout: Option<Duration>,
5001 pub(crate) timeout_seconds: Option<u64>,
5003}
5004
5005impl SymbolBuildOptions {
5006 pub(crate) fn new(
5008 max_bytes: u64,
5009 max_workers: Option<usize>,
5010 timeout_seconds: Option<u64>,
5011 ) -> Self {
5012 Self {
5013 max_bytes: max_bytes.min(MAX_SYMBOL_FILE_BYTES),
5014 max_workers: max_workers.filter(|workers| *workers > 0),
5015 timeout: timeout_seconds.map(Duration::from_secs),
5016 timeout_seconds,
5017 }
5018 }
5019
5020 #[must_use]
5022 pub(crate) fn with_worker_ceiling(mut self, max_workers: usize) -> Self {
5023 let ceiling = max_workers.max(1);
5024 self.max_workers = Some(
5025 self.max_workers
5026 .map_or(ceiling, |workers| workers.min(ceiling)),
5027 );
5028 self
5029 }
5030
5031 pub(crate) fn reported_workers(self) -> usize {
5033 self.effective_workers()
5034 }
5035
5036 fn effective_workers(self) -> usize {
5038 let available = thread::available_parallelism().map_or(1, usize::from);
5039 self.max_workers
5040 .unwrap_or(available)
5041 .min(available)
5042 .min(INDEX_WORKER_SAFE_CEILING)
5043 }
5044
5045 pub(crate) fn is_timed_out(self, started_at: Instant) -> bool {
5047 self.timeout
5048 .is_some_and(|timeout| started_at.elapsed() >= timeout)
5049 }
5050}
5051
5052fn worker_count_for_work(work_items: usize, max_workers: usize) -> usize {
5054 work_items.min(max_workers.clamp(1, INDEX_WORKER_SAFE_CEILING))
5055}
5056
5057#[derive(Clone, Copy, Debug)]
5059struct SymbolPublicationLimits {
5060 symbol_rows: u64,
5062 relation_rows: u64,
5064 output_bytes: u64,
5066}
5067
5068impl SymbolPublicationLimits {
5069 const STANDARD: Self = Self {
5071 symbol_rows: 2_000_000,
5072 relation_rows: 8_000_000,
5073 output_bytes: MAX_PUBLICATION_STAGING_BYTES,
5074 };
5075}
5076
5077pub(crate) fn index_work_control(options: &SymbolBuildOptions) -> IndexWorkControl {
5079 IndexWorkControl::new(IndexCancellation::new(), options.timeout)
5080 .with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
5081 .with_worker_ceiling(options.effective_workers())
5082}
5083
5084pub(crate) fn standalone_index_work_control() -> IndexWorkControl {
5086 IndexWorkControl::new(IndexCancellation::new(), Some(DEFAULT_INDEX_WORK_TIMEOUT))
5087}
5088
5089fn bounded_index_work_control(control: &IndexWorkControl) -> IndexWorkControl {
5091 control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT)
5092}
5093
5094#[derive(Clone, Debug)]
5096pub(crate) struct SymbolParseJob {
5097 pub(crate) path: String,
5099 native_path: PathBuf,
5101 expected_content_hash: String,
5103 language: Option<String>,
5105 fallback_summary: Option<String>,
5107 purpose_needs_suggestion: bool,
5109}
5110
5111#[derive(Debug)]
5113pub(crate) struct SymbolParseSuccess {
5114 pub(crate) path: String,
5116 graph: SymbolGraph,
5118 source_parser: ParserKind,
5120 summary: String,
5122 summary_is_structural: bool,
5124 purpose_suggestion: Option<String>,
5126}
5127
5128#[derive(Debug)]
5130pub(crate) enum SymbolParseOutcome {
5131 Parsed(SymbolParseSuccess),
5133 BinaryOrNonUtf8 {
5135 path: String,
5137 },
5138 SourceChanged {
5140 path: String,
5142 },
5143 Io {
5145 path: PathBuf,
5147 source: io::Error,
5149 },
5150 IndexWork(IndexWorkFailure),
5152}
5153
5154#[derive(Debug)]
5156enum SourceReadFailure {
5157 Io(io::Error),
5159 IndexWork(IndexWorkFailure),
5161 LimitExceeded {
5163 observed: u64,
5165 },
5166}
5167
5168fn read_source_bytes_controlled(
5170 path: &Path,
5171 max_bytes: u64,
5172 stage: IndexWorkStage,
5173 control: &IndexWorkControl,
5174) -> Result<Vec<u8>, SourceReadFailure> {
5175 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
5176 let mut file = fs::File::open(path).map_err(SourceReadFailure::Io)?;
5177 let mut bytes = Vec::new();
5178 let mut buffer = [0_u8; CONTROLLED_SOURCE_READ_BUFFER_BYTES];
5179 loop {
5180 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
5181 let count = file.read(&mut buffer).map_err(SourceReadFailure::Io)?;
5182 if count == 0 {
5183 break;
5184 }
5185 let observed = u64::try_from(bytes.len())
5186 .unwrap_or(u64::MAX)
5187 .saturating_add(count as u64);
5188 if observed > max_bytes {
5189 return Err(SourceReadFailure::LimitExceeded { observed });
5190 }
5191 bytes.extend_from_slice(&buffer[..count]);
5192 }
5193 control.check(stage).map_err(SourceReadFailure::IndexWork)?;
5194 Ok(bytes)
5195}
5196
5197#[cfg(test)]
5199fn build_symbols_for_paths_with_limits(
5200 store: &mut AtlasStore,
5201 root: &Path,
5202 options: &SymbolBuildOptions,
5203 previous_hashes: Option<&HashMap<String, String>>,
5204 target_paths: Option<&HashSet<String>>,
5205 control: &IndexWorkControl,
5206 limits: SymbolPublicationLimits,
5207) -> Result<SymbolBuildReport, CliError> {
5208 let nodes = if let Some(paths) = target_paths {
5209 let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
5210 sorted_paths.sort();
5211 store
5212 .load_nodes_by_paths(&sorted_paths)?
5213 .into_iter()
5214 .map(|indexed| indexed.node)
5215 .collect::<Vec<_>>()
5216 } else {
5217 store
5218 .load_nodes()?
5219 .into_iter()
5220 .map(|indexed| indexed.node)
5221 .collect::<Vec<_>>()
5222 };
5223 #[cfg(feature = "optional-parser-supervisor")]
5224 let optional_parser_selection =
5225 OptionalParserPackLifecycle::new(root, None)?.derive_project_selection()?;
5226 let staged = stage_symbols_for_nodes_with_limits(
5227 store,
5228 root,
5229 #[cfg(feature = "optional-parser-supervisor")]
5230 &optional_parser_selection,
5231 &nodes,
5232 options,
5233 previous_hashes,
5234 target_paths,
5235 &HashSet::new(),
5236 control,
5237 limits,
5238 )?;
5239 apply_symbol_build_stage(store, &staged, control)?;
5240 Ok(staged.report)
5241}
5242
5243#[allow(clippy::too_many_arguments)]
5245fn stage_symbols_for_nodes_with_limits(
5246 store: &AtlasStore,
5247 root: &Path,
5248 #[cfg(feature = "optional-parser-supervisor")]
5249 optional_parser_selection: &OptionalParserPackProjectSelection,
5250 nodes: &[Node],
5251 options: &SymbolBuildOptions,
5252 previous_hashes: Option<&HashMap<String, String>>,
5253 target_paths: Option<&HashSet<String>>,
5254 protected_purpose_paths: &HashSet<String>,
5255 control: &IndexWorkControl,
5256 limits: SymbolPublicationLimits,
5257) -> Result<SymbolBuildStage, CliError> {
5258 control.check(IndexWorkStage::SymbolParsing)?;
5259 #[cfg(feature = "optional-parser-supervisor")]
5260 let admit_optional_languages = optional_parser_selection.selection_key().is_some();
5261 #[cfg(not(feature = "optional-parser-supervisor"))]
5262 let admit_optional_languages = false;
5263 let root = root.canonicalize().map_err(|source| CliError::Io {
5264 path: root.to_path_buf(),
5265 source,
5266 })?;
5267 let considered_paths = nodes
5268 .iter()
5269 .filter(|node| node.kind == NodeKind::File)
5270 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5271 .map(|node| node.path.clone())
5272 .collect::<Vec<_>>();
5273 let previously_parsed_paths = store.source_parse_metadata_paths_for_paths(&considered_paths)?;
5274 let mut candidate_paths = nodes
5275 .iter()
5276 .filter(|node| node.kind == NodeKind::File)
5277 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5278 .filter(|node| {
5279 is_symbol_candidate_for_admission(
5280 &node.path,
5281 node.language.as_deref(),
5282 admit_optional_languages,
5283 )
5284 })
5285 .map(|node| node.path.clone())
5286 .collect::<Vec<_>>();
5287 candidate_paths.sort();
5288 let existing_nodes = store
5289 .load_nodes_by_paths(&candidate_paths)?
5290 .into_iter()
5291 .map(|indexed| (indexed.node.path.clone(), indexed))
5292 .collect::<HashMap<_, _>>();
5293 let symbol_counts = store.symbol_counts_for_paths(&candidate_paths)?;
5294 let mut report = SymbolBuildReport {
5295 candidates: 0,
5296 parsed: 0,
5297 unchanged: 0,
5298 too_large: 0,
5299 binary_or_non_utf8: 0,
5300 timed_out: 0,
5301 max_workers: options.reported_workers(),
5302 timeout_seconds: options.timeout_seconds,
5303 symbols: 0,
5304 relations: 0,
5305 summaries: 0,
5306 purpose_suggestions: 0,
5307 };
5308 let mut jobs = Vec::new();
5309 let mut changes = Vec::new();
5310 let mut output_bytes = 0_u64;
5311 for node in nodes
5312 .iter()
5313 .filter(|node| node.kind == NodeKind::File)
5314 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5315 .filter(|node| {
5316 !is_symbol_candidate_for_admission(
5317 &node.path,
5318 node.language.as_deref(),
5319 admit_optional_languages,
5320 ) && previously_parsed_paths.contains(&node.path)
5321 })
5322 {
5323 output_bytes = checked_symbol_publication_usage(
5324 output_bytes,
5325 node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
5326 limits.output_bytes,
5327 IndexWorkResource::OutputBytes,
5328 )?;
5329 changes.push(SymbolProjectionChange::Clear {
5330 path: node.path.clone(),
5331 language: node.language.clone(),
5332 });
5333 }
5334 for node in nodes
5335 .iter()
5336 .filter(|node| node.kind == NodeKind::File)
5337 .filter(|node| target_paths.is_none_or(|paths| paths.contains(&node.path)))
5338 .filter(|node| {
5339 is_symbol_candidate_for_admission(
5340 &node.path,
5341 node.language.as_deref(),
5342 admit_optional_languages,
5343 )
5344 })
5345 {
5346 control.check(IndexWorkStage::SymbolParsing)?;
5347 report.candidates += 1;
5348 if node.size_bytes.is_some_and(|size| size > options.max_bytes) {
5349 output_bytes = checked_symbol_publication_usage(
5350 output_bytes,
5351 node.path.len() as u64 + node.language.as_ref().map_or(0, String::len) as u64,
5352 limits.output_bytes,
5353 IndexWorkResource::OutputBytes,
5354 )?;
5355 changes.push(SymbolProjectionChange::Clear {
5356 path: node.path.clone(),
5357 language: node.language.clone(),
5358 });
5359 report.too_large += 1;
5360 continue;
5361 }
5362 let symbol_count = symbol_counts.get(&node.path).copied().unwrap_or_default();
5363 if node.content_hash.as_ref().is_some_and(|hash| {
5364 previous_hashes.and_then(|hashes| hashes.get(&node.path)) == Some(hash)
5365 }) {
5366 let has_source_index =
5367 symbol_count > 0 || store.load_source_parse_metadata(&node.path)?.is_some();
5368 if has_source_index {
5369 report.unchanged += 1;
5370 continue;
5371 }
5372 }
5373 let existing = existing_nodes.get(&node.path);
5374 jobs.push(SymbolParseJob {
5375 path: node.path.clone(),
5376 native_path: root.join(repo_path_to_native(&node.path)),
5377 expected_content_hash: node
5378 .content_hash
5379 .clone()
5380 .ok_or_else(|| source_changed_during_derivation(&root, &node.path))?,
5381 language: node.language.clone(),
5382 fallback_summary: existing.and_then(|indexed| indexed.summary.clone()),
5383 purpose_needs_suggestion: !protected_purpose_paths.contains(&node.path)
5384 && existing.is_none_or(|indexed| {
5385 matches!(
5386 indexed.purpose.status,
5387 PurposeStatus::Missing | PurposeStatus::Suggested
5388 )
5389 }),
5390 });
5391 if jobs.len() > MAX_SYMBOL_PARSE_JOBS {
5392 return Err(IndexWorkFailure::resource_limit(
5393 IndexWorkStage::SymbolParsing,
5394 IndexWorkResource::SymbolJobs,
5395 MAX_SYMBOL_PARSE_JOBS as u64,
5396 jobs.len() as u64,
5397 )
5398 .into());
5399 }
5400 }
5401 report.max_workers = worker_count_for_work(jobs.len(), report.max_workers);
5402 if !jobs.is_empty() {
5403 let pool = ThreadPoolBuilder::new()
5404 .num_threads(report.max_workers)
5405 .build()
5406 .map_err(|source| {
5407 CliError::InvalidInput(format!("symbol worker pool failed: {source}"))
5408 })?;
5409 #[cfg(feature = "optional-parser-supervisor")]
5410 let outcomes = optional_parser_runtime::parse_symbol_jobs_controlled(
5411 &root,
5412 optional_parser_selection,
5413 &pool,
5414 &jobs,
5415 options,
5416 control,
5417 )?;
5418 #[cfg(not(feature = "optional-parser-supervisor"))]
5419 let outcomes = parse_symbol_job_batches_controlled(&pool, &jobs, options, control)?;
5420 for outcome in outcomes {
5421 match outcome {
5422 SymbolParseOutcome::Parsed(parsed) => {
5423 let next_symbols = checked_symbol_publication_usage(
5424 report.symbols as u64,
5425 parsed.graph.symbols.len() as u64,
5426 limits.symbol_rows,
5427 IndexWorkResource::SymbolRows,
5428 )?;
5429 let next_relations = checked_symbol_publication_usage(
5430 report.relations as u64,
5431 parsed.graph.relations.len() as u64,
5432 limits.relation_rows,
5433 IndexWorkResource::RelationRows,
5434 )?;
5435 let next_output_bytes = checked_symbol_publication_usage(
5436 output_bytes,
5437 symbol_parse_output_bytes(&parsed),
5438 limits.output_bytes,
5439 IndexWorkResource::OutputBytes,
5440 )?;
5441 report.summaries += 1;
5442 if parsed.purpose_suggestion.is_some() {
5443 report.purpose_suggestions += 1;
5444 }
5445 report.parsed += 1;
5446 report.symbols = next_symbols as usize;
5447 report.relations = next_relations as usize;
5448 output_bytes = next_output_bytes;
5449 changes.push(SymbolProjectionChange::Parsed(parsed));
5450 }
5451 SymbolParseOutcome::BinaryOrNonUtf8 { path } => {
5452 let language = nodes
5453 .iter()
5454 .find(|node| node.path == path)
5455 .and_then(|node| node.language.clone());
5456 output_bytes = checked_symbol_publication_usage(
5457 output_bytes,
5458 path.len() as u64 + language.as_ref().map_or(0, String::len) as u64,
5459 limits.output_bytes,
5460 IndexWorkResource::OutputBytes,
5461 )?;
5462 changes.push(SymbolProjectionChange::Clear { path, language });
5463 report.binary_or_non_utf8 += 1;
5464 }
5465 SymbolParseOutcome::SourceChanged { path } => {
5466 return Err(source_changed_during_derivation(&root, &path));
5467 }
5468 SymbolParseOutcome::Io { path, source } => {
5469 return Err(CliError::Io { path, source });
5470 }
5471 SymbolParseOutcome::IndexWork(failure) => return Err(failure.into()),
5472 }
5473 }
5474 }
5475 control.check(IndexWorkStage::SymbolParsing)?;
5476 Ok(SymbolBuildStage {
5477 report,
5478 changes,
5479 retained_bytes: output_bytes,
5480 })
5481}
5482
5483#[cfg(not(feature = "optional-parser-supervisor"))]
5485fn parse_symbol_job_batches_controlled(
5486 pool: &rayon::ThreadPool,
5487 jobs: &[SymbolParseJob],
5488 options: &SymbolBuildOptions,
5489 control: &IndexWorkControl,
5490) -> Result<Vec<SymbolParseOutcome>, CliError> {
5491 let mut outcomes = Vec::with_capacity(jobs.len());
5492 for batch in jobs.chunks(SYMBOL_PARSE_BATCH_SIZE) {
5493 control.check(IndexWorkStage::SymbolParsing)?;
5494 outcomes.extend(parse_symbol_jobs_controlled(pool, batch, options, control)?);
5495 }
5496 Ok(outcomes)
5497}
5498
5499fn apply_symbol_build_stage(
5501 store: &mut AtlasStore,
5502 staged: &SymbolBuildStage,
5503 control: &IndexWorkControl,
5504) -> Result<(), CliError> {
5505 for change in &staged.changes {
5506 control.check(IndexWorkStage::Publication)?;
5507 match change {
5508 SymbolProjectionChange::Parsed(parsed) => {
5509 store.set_node_summary(&parsed.path, &parsed.summary)?;
5510 if let Some(suggestion) = parsed.purpose_suggestion.as_deref() {
5511 store.set_suggested_purpose(&parsed.path, suggestion)?;
5512 }
5513 let mut metadata = SourceParseMetadata::from_graph(&parsed.graph);
5514 metadata.parser = parsed.source_parser;
5515 store.replace_symbol_graph_with_metadata(&parsed.graph, &metadata)?;
5516 }
5517 SymbolProjectionChange::Clear { path, language } => {
5518 clear_skipped_symbol_index(store, path, language.as_deref())?;
5519 }
5520 }
5521 }
5522 control.check(IndexWorkStage::Publication)?;
5523 Ok(())
5524}
5525
5526#[cfg(not(feature = "optional-parser-supervisor"))]
5528fn parse_symbol_jobs_controlled(
5529 pool: &rayon::ThreadPool,
5530 jobs: &[SymbolParseJob],
5531 options: &SymbolBuildOptions,
5532 control: &IndexWorkControl,
5533) -> Result<Vec<SymbolParseOutcome>, CliError> {
5534 control.check(IndexWorkStage::SymbolParsing)?;
5535 Ok(pool.install(|| {
5536 jobs.par_iter()
5537 .map(|job| parse_symbol_job_controlled(job, options, control))
5538 .collect::<Vec<_>>()
5539 }))
5540}
5541
5542fn checked_symbol_publication_usage(
5544 current: u64,
5545 added: u64,
5546 limit: u64,
5547 resource: IndexWorkResource,
5548) -> Result<u64, CliError> {
5549 let observed = current.saturating_add(added);
5550 if observed > limit {
5551 return Err(IndexWorkFailure::resource_limit(
5552 IndexWorkStage::SymbolParsing,
5553 resource,
5554 limit,
5555 observed,
5556 )
5557 .into());
5558 }
5559 Ok(observed)
5560}
5561
5562fn symbol_parse_output_bytes(parsed: &SymbolParseSuccess) -> u64 {
5564 let graph = &parsed.graph;
5565 let mut bytes = graph.path.len() as u64
5566 + graph.language.as_ref().map_or(0, String::len) as u64
5567 + parsed.summary.len() as u64
5568 + parsed.purpose_suggestion.as_ref().map_or(0, String::len) as u64;
5569 for symbol in &graph.symbols {
5570 bytes = bytes.saturating_add(
5571 symbol.path.len() as u64
5572 + symbol.language.as_ref().map_or(0, String::len) as u64
5573 + symbol.name.len() as u64
5574 + symbol.signature.len() as u64
5575 + symbol.documentation.as_ref().map_or(0, String::len) as u64
5576 + symbol.parent.as_ref().map_or(0, String::len) as u64
5577 + symbol.detail.as_ref().map_or(0, String::len) as u64,
5578 );
5579 }
5580 for relation in &graph.relations {
5581 bytes = bytes.saturating_add(
5582 relation.path.len() as u64
5583 + relation.source_name.len() as u64
5584 + relation.target_name.len() as u64
5585 + relation.context.len() as u64,
5586 );
5587 }
5588 bytes
5589}
5590
5591#[cfg(test)]
5593pub(crate) fn parse_symbol_job(
5594 job: &SymbolParseJob,
5595 options: &SymbolBuildOptions,
5596 started_at: Instant,
5597) -> SymbolParseOutcome {
5598 let control = options
5599 .timeout
5600 .and_then(|timeout| started_at.checked_add(timeout))
5601 .map_or_else(
5602 || IndexWorkControl::new(IndexCancellation::new(), None),
5603 |deadline| IndexWorkControl::with_deadline(IndexCancellation::new(), deadline),
5604 );
5605 parse_symbol_job_controlled(job, options, &control)
5606}
5607
5608fn parse_symbol_job_controlled(
5610 job: &SymbolParseJob,
5611 options: &SymbolBuildOptions,
5612 control: &IndexWorkControl,
5613) -> SymbolParseOutcome {
5614 if options.is_timed_out(control.started_at()) {
5615 return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
5616 stage: IndexWorkStage::SymbolParsing,
5617 });
5618 }
5619 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
5620 return SymbolParseOutcome::IndexWork(failure);
5621 }
5622 let content = match admit_symbol_job_source(job, options, control) {
5623 Ok(content) => content,
5624 Err(outcome) => return *outcome,
5625 };
5626 parse_admitted_symbol_job(job, &content, None, options, control)
5627}
5628
5629fn admit_symbol_job_source(
5631 job: &SymbolParseJob,
5632 options: &SymbolBuildOptions,
5633 control: &IndexWorkControl,
5634) -> Result<String, Box<SymbolParseOutcome>> {
5635 let bytes = match read_source_bytes_controlled(
5636 &job.native_path,
5637 options.max_bytes,
5638 IndexWorkStage::SymbolParsing,
5639 control,
5640 ) {
5641 Ok(bytes) => bytes,
5642 Err(SourceReadFailure::Io(source)) => {
5643 return Err(Box::new(SymbolParseOutcome::Io {
5644 path: job.native_path.clone(),
5645 source,
5646 }));
5647 }
5648 Err(SourceReadFailure::IndexWork(failure)) => {
5649 return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
5650 }
5651 Err(SourceReadFailure::LimitExceeded { observed }) => {
5652 return Err(Box::new(SymbolParseOutcome::IndexWork(
5653 IndexWorkFailure::resource_limit(
5654 IndexWorkStage::SymbolParsing,
5655 IndexWorkResource::SourceBytes,
5656 options.max_bytes,
5657 observed,
5658 ),
5659 )));
5660 }
5661 };
5662 if let Err(failure) = control.check(IndexWorkStage::SymbolParsing) {
5663 return Err(Box::new(SymbolParseOutcome::IndexWork(failure)));
5664 }
5665 if blake3::hash(&bytes).to_hex().as_str() != job.expected_content_hash {
5666 return Err(Box::new(SymbolParseOutcome::SourceChanged {
5667 path: job.path.clone(),
5668 }));
5669 }
5670 let Ok(content) = String::from_utf8(bytes) else {
5671 return Err(Box::new(SymbolParseOutcome::BinaryOrNonUtf8 {
5672 path: job.path.clone(),
5673 }));
5674 };
5675 Ok(content)
5676}
5677
5678fn parse_admitted_symbol_job(
5680 job: &SymbolParseJob,
5681 content: &str,
5682 source_parser: Option<ParserKind>,
5683 options: &SymbolBuildOptions,
5684 control: &IndexWorkControl,
5685) -> SymbolParseOutcome {
5686 if options.is_timed_out(control.started_at()) {
5687 return SymbolParseOutcome::IndexWork(IndexWorkFailure::DeadlineExceeded {
5688 stage: IndexWorkStage::SymbolParsing,
5689 });
5690 }
5691 let graph =
5692 match extract_symbol_graph_controlled(&job.path, job.language.as_deref(), content, control)
5693 {
5694 Ok(graph) => graph,
5695 Err(failure) => return SymbolParseOutcome::IndexWork(failure),
5696 };
5697 let source_parser = source_parser.unwrap_or(graph.parser);
5698 let structural_summary = graph
5699 .symbols
5700 .is_empty()
5701 .then(|| structural_summary_for_path(&job.path, job.language.as_deref(), content));
5702 let structural_summary = structural_summary.flatten();
5703 let summary_is_structural = structural_summary.is_some();
5704 let summary = structural_summary
5705 .unwrap_or_else(|| summarize_symbol_graph(&graph, job.fallback_summary.as_deref()));
5706 let purpose_suggestion = job
5707 .purpose_needs_suggestion
5708 .then(|| suggest_file_purpose(&job.path, &summary));
5709 SymbolParseOutcome::Parsed(SymbolParseSuccess {
5710 path: job.path.clone(),
5711 graph,
5712 source_parser,
5713 summary,
5714 summary_is_structural,
5715 purpose_suggestion,
5716 })
5717}
5718
5719pub(crate) fn empty_symbol_build_report() -> SymbolBuildReport {
5721 SymbolBuildReport {
5722 candidates: 0,
5723 parsed: 0,
5724 unchanged: 0,
5725 too_large: 0,
5726 binary_or_non_utf8: 0,
5727 timed_out: 0,
5728 max_workers: 0,
5729 timeout_seconds: None,
5730 symbols: 0,
5731 relations: 0,
5732 summaries: 0,
5733 purpose_suggestions: 0,
5734 }
5735}
5736
5737fn empty_text_index_report(options: TextIndexOptions) -> TextIndexReport {
5739 TextIndexReport {
5740 candidates: 0,
5741 indexed: 0,
5742 binary_or_non_utf8: 0,
5743 too_large: 0,
5744 skipped: 0,
5745 max_bytes: options.max_bytes,
5746 bytes: 0,
5747 }
5748}
5749
5750pub(crate) fn summarize_symbol_graph(graph: &SymbolGraph, fallback: Option<&str>) -> String {
5752 if graph.symbols.is_empty() {
5753 if let Some(fallback) = fallback.filter(|summary| !is_scanner_fallback_summary(summary)) {
5754 return fallback.to_string();
5755 }
5756 let language = observed_language_label(graph.language.as_deref());
5757 return format!("{language} source file with no declarations found.");
5758 }
5759 let language = observed_language_label(graph.language.as_deref());
5760 let primary_names = primary_symbol_names(graph, 4);
5761 let primary_kinds = primary_symbol_kinds(graph);
5762 let imports = relation_targets(graph, RelationKind::Imports, 2);
5763 let dependencies = relation_targets(graph, RelationKind::DependsOn, 3);
5764 if !dependencies.is_empty() {
5765 let subject = observed_manifest_subject(&language);
5766 return format!(
5767 "{subject} declaring {} and depending on {}.",
5768 primary_names.join(", "),
5769 dependencies.join(", ")
5770 );
5771 }
5772 if !imports.is_empty() {
5773 return format!(
5774 "{language} source defining {} {} with imports {}.",
5775 primary_kinds,
5776 primary_names.join(", "),
5777 imports.join(", ")
5778 );
5779 }
5780 format!(
5781 "{language} source defining {} {}.",
5782 primary_kinds,
5783 primary_names.join(", ")
5784 )
5785}
5786
5787fn observed_language_label(language: Option<&str>) -> String {
5789 match language.unwrap_or("source") {
5790 "cargo-manifest" => "cargo manifest".to_string(),
5791 "cargo-lock" => "cargo lock".to_string(),
5792 "rust-build-script" => "rust build script".to_string(),
5793 "objective-c" => "Objective-C".to_string(),
5794 "csharp" => "C#".to_string(),
5795 "cpp" => "C++".to_string(),
5796 other => other.replace('-', " "),
5797 }
5798}
5799
5800fn observed_manifest_subject(language: &str) -> String {
5802 if language.contains("manifest") {
5803 language.to_string()
5804 } else {
5805 format!("{language} manifest")
5806 }
5807}
5808
5809pub(crate) fn primary_symbol_kinds(graph: &SymbolGraph) -> String {
5811 let mut function_like = 0_usize;
5812 let mut type_like = 0_usize;
5813 let mut manifest_like = 0_usize;
5814 let mut value_like = 0_usize;
5815 for symbol in &graph.symbols {
5816 match symbol.kind {
5817 SymbolKind::Function | SymbolKind::Method => function_like += 1,
5818 SymbolKind::Class
5819 | SymbolKind::Struct
5820 | SymbolKind::Enum
5821 | SymbolKind::Trait
5822 | SymbolKind::Interface
5823 | SymbolKind::Type => type_like += 1,
5824 SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Dependency => {
5825 manifest_like += 1;
5826 }
5827 SymbolKind::Value => value_like += 1,
5828 SymbolKind::Module | SymbolKind::Import | SymbolKind::Unknown => {}
5829 }
5830 }
5831 if manifest_like > 0 && function_like == 0 && type_like == 0 {
5832 return "manifest entries".to_string();
5833 }
5834 if value_like > 0 && function_like == 0 && type_like == 0 {
5835 return value_only_symbol_kind_label(graph, value_like);
5836 }
5837 match (type_like, function_like) {
5838 (0, 0) => "symbols".to_string(),
5839 (0, 1) => "function".to_string(),
5840 (0, _) => "functions".to_string(),
5841 (1, 0) => "type".to_string(),
5842 (_, 0) => "types".to_string(),
5843 (1, 1) => "type and function".to_string(),
5844 (1, _) => "type and functions".to_string(),
5845 (_, 1) => "types and function".to_string(),
5846 (_, _) => "types and functions".to_string(),
5847 }
5848}
5849
5850pub(crate) fn value_only_symbol_kind_label(graph: &SymbolGraph, count: usize) -> String {
5852 let language = graph.language.as_deref().unwrap_or_default();
5853 let binding_language = matches!(
5854 language,
5855 "javascript" | "typescript" | "tsx" | "vue" | "svelte"
5856 ) || graph
5857 .symbols
5858 .iter()
5859 .any(|symbol| symbol.detail.as_deref() == Some("fallback-composition-binding"));
5860 let singular = if binding_language { "binding" } else { "value" };
5861 let plural = if binding_language {
5862 "bindings"
5863 } else {
5864 "values"
5865 };
5866 if count == 1 {
5867 singular.to_string()
5868 } else {
5869 plural.to_string()
5870 }
5871}
5872
5873pub(crate) fn primary_symbol_names(graph: &SymbolGraph, limit: usize) -> Vec<String> {
5875 let has_primary_definitions = graph.symbols.iter().any(|symbol| {
5876 matches!(
5877 symbol.kind,
5878 SymbolKind::Function
5879 | SymbolKind::Method
5880 | SymbolKind::Class
5881 | SymbolKind::Struct
5882 | SymbolKind::Enum
5883 | SymbolKind::Trait
5884 | SymbolKind::Interface
5885 | SymbolKind::Type
5886 )
5887 });
5888 let mut names = graph
5889 .symbols
5890 .iter()
5891 .filter(|symbol| {
5892 if has_primary_definitions && symbol.kind == SymbolKind::Value {
5893 return false;
5894 }
5895 !matches!(
5896 symbol.kind,
5897 SymbolKind::Import
5898 | SymbolKind::Dependency
5899 | SymbolKind::Module
5900 | SymbolKind::Unknown
5901 )
5902 })
5903 .map(|symbol| symbol.name.clone())
5904 .collect::<Vec<_>>();
5905 if names.is_empty() {
5906 names = graph
5907 .symbols
5908 .iter()
5909 .map(|symbol| symbol.name.clone())
5910 .collect::<Vec<_>>();
5911 }
5912 names.sort();
5913 names.dedup();
5914 names.truncate(limit);
5915 if names.is_empty() {
5916 vec!["indexed symbols".to_string()]
5917 } else {
5918 names
5919 }
5920}
5921
5922pub(crate) fn relation_targets(
5924 graph: &SymbolGraph,
5925 kind: RelationKind,
5926 limit: usize,
5927) -> Vec<String> {
5928 let mut targets = graph
5929 .relations
5930 .iter()
5931 .filter(|relation| relation.kind == kind)
5932 .map(|relation| relation.target_name.clone())
5933 .collect::<Vec<_>>();
5934 targets.sort();
5935 targets.dedup();
5936 targets.truncate(limit);
5937 targets
5938}
5939
5940pub(crate) fn suggest_file_purpose(path: &str, summary: &str) -> String {
5942 let subject = path_context_subject(path);
5943 if summary.contains("dataset manifest") {
5944 if let Some(datasets) = summary_between(summary, " including ", " and keys") {
5945 format!("Define the {subject} dataset manifest for {datasets}.")
5946 } else {
5947 format!("Define the {subject} dataset manifest.")
5948 }
5949 } else if let Some(workflow) = summary_between(summary, "yaml workflow ", " triggered") {
5950 format!("Define the {workflow} workflow.")
5951 } else if summary.contains("manifest") {
5952 if let Some(package) = summary_between(summary, " manifest for ", " with ") {
5953 format!("Define the {package} manifest.")
5954 } else {
5955 format!("Define the {subject} manifest.")
5956 }
5957 } else if let Some(title) = summary_between(summary, "document titled ", " with ") {
5958 format!("Document {title}.")
5959 } else if summary.contains("stylesheet") {
5960 format!("Style the {subject} stylesheet.")
5961 } else if summary.contains("config") {
5962 format!("Configure the {subject}.")
5963 } else if is_gradle_build_script(path) {
5964 if let Some(declarations) = summary_primary_declarations(summary) {
5965 format!("Define Gradle build tasks around {declarations}.")
5966 } else {
5967 "Configure the Gradle build.".to_string()
5968 }
5969 } else if let Some(declarations) = summary_primary_declarations(summary) {
5970 format!("Implement the {subject} source around {declarations}.")
5971 } else if summary.contains("source") {
5972 format!("Implement the {subject} source.")
5973 } else {
5974 format!("Implement the {subject}.")
5975 }
5976}
5977
5978fn is_gradle_build_script(path: &str) -> bool {
5980 let normalized = path.replace('\\', "/");
5981 normalized.ends_with("build.gradle") || normalized.ends_with("build.gradle.kts")
5982}
5983
5984fn path_context_subject(path: &str) -> String {
5986 let normalized = path.replace('\\', "/");
5987 let mut segments = normalized
5988 .split('/')
5989 .filter(|segment| !segment.is_empty() && *segment != ".")
5990 .collect::<Vec<_>>();
5991 let Some(file_name) = segments.pop() else {
5992 return "path".to_string();
5993 };
5994 let stem = file_name
5995 .rsplit_once('.')
5996 .map_or(file_name, |(stem, _)| stem);
5997 let stem_words = path_segment_words(stem);
5998 let parent_words = segments
5999 .iter()
6000 .rev()
6001 .find(|segment| !is_generic_context_segment(segment))
6002 .map(|segment| path_segment_words(segment));
6003 match parent_words {
6004 Some(parent) if !parent.is_empty() && parent != stem_words => {
6005 format!("{parent} {stem_words}")
6006 }
6007 _ => stem_words,
6008 }
6009}
6010
6011fn path_segment_words(segment: &str) -> String {
6013 let mut words = String::new();
6014 let mut previous_lowercase = false;
6015 for character in segment.chars() {
6016 if character == '-' || character == '_' || character == '.' {
6017 push_word_space(&mut words);
6018 previous_lowercase = false;
6019 continue;
6020 }
6021 if character.is_uppercase() && previous_lowercase {
6022 push_word_space(&mut words);
6023 }
6024 words.extend(character.to_lowercase());
6025 previous_lowercase = character.is_lowercase() || character.is_ascii_digit();
6026 }
6027 let words = words.trim();
6028 if words.is_empty() {
6029 "path".to_string()
6030 } else {
6031 words.to_string()
6032 }
6033}
6034
6035fn push_word_space(words: &mut String) {
6037 if !words.ends_with(' ') && !words.is_empty() {
6038 words.push(' ');
6039 }
6040}
6041
6042fn is_generic_context_segment(segment: &str) -> bool {
6044 matches!(
6045 segment.to_ascii_lowercase().as_str(),
6046 "src"
6047 | "source"
6048 | "sources"
6049 | "app"
6050 | "apps"
6051 | "lib"
6052 | "libs"
6053 | "crate"
6054 | "crates"
6055 | "package"
6056 | "packages"
6057 | "test"
6058 | "tests"
6059 | "spec"
6060 | "specs"
6061 | "fixture"
6062 | "fixtures"
6063 | "example"
6064 | "examples"
6065 | "script"
6066 | "scripts"
6067 )
6068}
6069
6070fn summary_primary_declarations(summary: &str) -> Option<String> {
6072 let after_marker = summary
6073 .split_once(" source defining ")
6074 .map(|(_, value)| value)
6075 .or_else(|| summary.split_once(" declaring ").map(|(_, value)| value))?;
6076 let declaration_clause = trim_summary_clause(after_marker);
6077 let names = strip_declaration_kind_prefix(declaration_clause)
6078 .split(',')
6079 .map(str::trim)
6080 .filter(|name| !name.is_empty())
6081 .take(3)
6082 .map(ToOwned::to_owned)
6083 .collect::<Vec<_>>();
6084 if names.is_empty() {
6085 None
6086 } else {
6087 Some(join_human_names(&names))
6088 }
6089}
6090
6091fn trim_summary_clause(value: &str) -> &str {
6093 value
6094 .split(" with imports ")
6095 .next()
6096 .unwrap_or(value)
6097 .split(" and depending on ")
6098 .next()
6099 .unwrap_or(value)
6100 .trim_end_matches('.')
6101 .trim()
6102}
6103
6104fn strip_declaration_kind_prefix(value: &str) -> &str {
6106 const PREFIXES: &[&str] = &[
6107 "types and functions ",
6108 "type and functions ",
6109 "types and function ",
6110 "type and function ",
6111 "manifest entries ",
6112 "functions ",
6113 "function ",
6114 "types ",
6115 "type ",
6116 "bindings ",
6117 "binding ",
6118 "values ",
6119 "value ",
6120 "symbols ",
6121 ];
6122 PREFIXES
6123 .iter()
6124 .find_map(|prefix| value.strip_prefix(prefix))
6125 .unwrap_or(value)
6126}
6127
6128fn join_human_names(names: &[String]) -> String {
6130 match names {
6131 [] => String::new(),
6132 [one] => one.clone(),
6133 [first, second] => format!("{first} and {second}"),
6134 [first, second, third, ..] => format!("{first}, {second}, and {third}"),
6135 }
6136}
6137
6138fn summary_between<'a>(summary: &'a str, start: &str, end: &str) -> Option<&'a str> {
6140 let after_start = summary.split_once(start)?.1;
6141 let value = after_start.split_once(end)?.0.trim();
6142 (!value.is_empty()).then_some(value)
6143}
6144
6145pub(crate) fn is_symbol_candidate(path: &str, language: Option<&str>) -> bool {
6147 let Some(language) = language else {
6148 return path.ends_with("Cargo.toml")
6149 || path.ends_with("Cargo.lock")
6150 || Path::new(path)
6151 .extension()
6152 .and_then(|extension| extension.to_str())
6153 .is_some_and(|extension| {
6154 ["vue", "ps1", "psm1", "psd1"]
6155 .iter()
6156 .any(|expected| extension.eq_ignore_ascii_case(expected))
6157 });
6158 };
6159 language_capability(language)
6160 .is_none_or(|capability| capability.symbol_parser != SymbolParserOwner::Unavailable)
6161}
6162
6163fn is_symbol_candidate_for_admission(
6165 path: &str,
6166 language: Option<&str>,
6167 admit_optional_languages: bool,
6168) -> bool {
6169 if !admit_optional_languages
6170 && language
6171 .and_then(language_capability)
6172 .is_some_and(|capability| capability.optional_pack.is_some())
6173 {
6174 return false;
6175 }
6176 is_symbol_candidate(path, language)
6177}
6178
6179fn clear_skipped_symbol_index(
6181 store: &AtlasStore,
6182 path: &str,
6183 language: Option<&str>,
6184) -> Result<(), CliError> {
6185 if is_structural_summary_candidate(path, language) {
6186 store.clear_symbol_graph_for_path(path)?;
6187 } else {
6188 store.clear_source_index_for_path(path)?;
6189 }
6190 Ok(())
6191}
6192
6193pub(crate) fn validated_file_key(file: &Path) -> Result<String, CliError> {
6195 validated_repo_file_key(file).map_err(|source| CliError::InvalidInput(source.to_string()))
6196}
6197
6198pub(crate) fn normalized_folder_filter(folder: &str) -> Result<String, CliError> {
6200 let trimmed = folder.trim().trim_end_matches(['/', '\\']);
6201 if trimmed.is_empty() || trimmed == "." {
6202 return Ok(".".to_string());
6203 }
6204 validated_file_key(Path::new(trimmed)).map_err(|_error| {
6205 CliError::InvalidInput(format!(
6206 "folder filter {folder:?} must be a project-relative path"
6207 ))
6208 })
6209}
6210
6211pub(crate) fn validated_indexed_file_key(
6213 store: &AtlasStore,
6214 file: &Path,
6215) -> Result<String, CliError> {
6216 let file_key = validated_file_key(file)?;
6217 let indexed = store
6218 .load_node_by_path(&file_key)?
6219 .ok_or_else(|| CliError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
6220 if indexed.node.kind != NodeKind::File {
6221 return Err(CliError::InvalidInput(format!(
6222 "path {file_key:?} is not an indexed file"
6223 )));
6224 }
6225 Ok(file_key)
6226}
6227
6228pub(crate) fn indexed_project_root(store: &AtlasStore) -> Result<PathBuf, CliError> {
6230 store.project_root()?.map(PathBuf::from).ok_or_else(|| {
6231 CliError::InvalidInput(
6232 "indexed project root is missing; run projectatlas scan <project-root> first"
6233 .to_string(),
6234 )
6235 })
6236}
6237
6238pub(crate) fn indexed_native_path(store: &AtlasStore, file_key: &str) -> Result<PathBuf, CliError> {
6240 Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
6241}
6242
6243pub(crate) fn read_indexed_file_content(
6245 store: &AtlasStore,
6246 file_key: &str,
6247) -> Result<String, CliError> {
6248 let native = indexed_native_path(store, file_key)?;
6249 let indexed = store.load_node_by_path(file_key)?.ok_or_else(|| {
6250 CliError::InvalidInput(format!("indexed file {file_key:?} was not found"))
6251 })?;
6252 let project_root = normalize_native_path_display(indexed_project_root(store)?);
6253 let metadata = match fs::metadata(&native) {
6254 Ok(metadata) => metadata,
6255 Err(source) if source.kind() == io::ErrorKind::NotFound => {
6256 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6257 project_root,
6258 status: IndexReadStatus::RefreshRequired,
6259 reason: IndexRefreshReason::PathsChanged,
6260 scope: IndexRefreshScope::Full,
6261 changed: 1,
6262 added: 0,
6263 removed: 1,
6264 modified: 0,
6265 sample_paths: vec![file_key.to_string()],
6266 })));
6267 }
6268 Err(source) => {
6269 return Err(CliError::VerificationIncomplete(Box::new(
6270 IndexVerificationIncomplete {
6271 project_root,
6272 status: IndexReadStatus::VerificationIncomplete,
6273 reason: IndexVerificationReason::SourceInspectionFailed,
6274 scope: IndexRefreshScope::Full,
6275 message: format!("failed to read '{}': {source}", native.display()),
6276 },
6277 )));
6278 }
6279 };
6280 if indexed
6281 .node
6282 .size_bytes
6283 .is_some_and(|indexed_bytes| indexed_bytes != metadata.len())
6284 {
6285 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6286 project_root,
6287 status: IndexReadStatus::RefreshRequired,
6288 reason: IndexRefreshReason::SourceChanged,
6289 scope: IndexRefreshScope::Full,
6290 changed: 1,
6291 added: 0,
6292 removed: 0,
6293 modified: 1,
6294 sample_paths: vec![file_key.to_string()],
6295 })));
6296 }
6297 if metadata.len() > MAX_INDEXED_NAVIGATION_SOURCE_BYTES {
6298 return Err(CliError::VerificationIncomplete(Box::new(
6299 IndexVerificationIncomplete {
6300 project_root,
6301 status: IndexReadStatus::VerificationIncomplete,
6302 reason: IndexVerificationReason::SourceTooLarge,
6303 scope: IndexRefreshScope::Full,
6304 message: format!(
6305 "indexed file {file_key:?} contains {} bytes; bounded navigation reads admit at most {MAX_INDEXED_NAVIGATION_SOURCE_BYTES} bytes",
6306 metadata.len()
6307 ),
6308 },
6309 )));
6310 }
6311 let file = fs::File::open(&native).map_err(|source| {
6312 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
6313 project_root: project_root.clone(),
6314 status: IndexReadStatus::VerificationIncomplete,
6315 reason: IndexVerificationReason::SourceInspectionFailed,
6316 scope: IndexRefreshScope::Full,
6317 message: format!("failed to open '{}': {source}", native.display()),
6318 }))
6319 })?;
6320 let mut bytes = Vec::with_capacity(metadata.len() as usize);
6321 file.take(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)
6322 .read_to_end(&mut bytes)
6323 .map_err(|source| {
6324 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
6325 project_root: project_root.clone(),
6326 status: IndexReadStatus::VerificationIncomplete,
6327 reason: IndexVerificationReason::SourceInspectionFailed,
6328 scope: IndexRefreshScope::Full,
6329 message: format!("failed to read '{}': {source}", native.display()),
6330 }))
6331 })?;
6332 if bytes.len() as u64 != metadata.len()
6333 || bytes.len() as u64 > MAX_INDEXED_NAVIGATION_SOURCE_BYTES
6334 {
6335 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6336 project_root,
6337 status: IndexReadStatus::RefreshRequired,
6338 reason: IndexRefreshReason::SourceChanged,
6339 scope: IndexRefreshScope::Full,
6340 changed: 1,
6341 added: 0,
6342 removed: 0,
6343 modified: 1,
6344 sample_paths: vec![file_key.to_string()],
6345 })));
6346 }
6347 let current_hash = blake3::hash(&bytes).to_hex().to_string();
6348 if indexed.node.content_hash.as_deref() != Some(current_hash.as_str()) {
6349 return Err(CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6350 project_root,
6351 status: IndexReadStatus::RefreshRequired,
6352 reason: IndexRefreshReason::SourceChanged,
6353 scope: IndexRefreshScope::Full,
6354 changed: 1,
6355 added: 0,
6356 removed: 0,
6357 modified: 1,
6358 sample_paths: vec![file_key.to_string()],
6359 })));
6360 }
6361 String::from_utf8(bytes).map_err(|source| {
6362 CliError::VerificationIncomplete(Box::new(IndexVerificationIncomplete {
6363 project_root,
6364 status: IndexReadStatus::VerificationIncomplete,
6365 reason: IndexVerificationReason::SourceInspectionFailed,
6366 scope: IndexRefreshScope::Full,
6367 message: format!("indexed file {file_key:?} is not valid UTF-8: {source}"),
6368 }))
6369 })
6370}
6371
6372pub(crate) fn run_watch_loop(
6374 store: &mut AtlasStore,
6375 plan: &ScanRuntimePlan,
6376 once: bool,
6377 poll_seconds: u64,
6378 max_cycles: usize,
6379 symbol_options: &SymbolBuildOptions,
6380) -> Result<WatchReport, CliError> {
6381 if once {
6382 return run_single_watch_refresh(store, plan, symbol_options);
6383 }
6384 run_watch_with_polling_fallback(
6385 store,
6386 plan,
6387 poll_seconds,
6388 max_cycles,
6389 symbol_options,
6390 |store| run_notify_watch_loop(store, plan, poll_seconds, max_cycles, symbol_options),
6391 )
6392}
6393
6394fn run_watch_with_polling_fallback<F>(
6396 store: &mut AtlasStore,
6397 plan: &ScanRuntimePlan,
6398 poll_seconds: u64,
6399 max_cycles: usize,
6400 symbol_options: &SymbolBuildOptions,
6401 run_notify: F,
6402) -> Result<WatchReport, CliError>
6403where
6404 F: FnOnce(&mut AtlasStore) -> Result<WatchReport, CliError>,
6405{
6406 match run_notify(store) {
6407 Ok(report) => Ok(report),
6408 Err(error @ CliError::RefreshRequired(_)) => Err(error),
6409 Err(error) => run_polling_watch_loop(
6410 store,
6411 plan,
6412 poll_seconds,
6413 max_cycles,
6414 symbol_options,
6415 Some(error.to_string()),
6416 ),
6417 }
6418}
6419
6420pub(crate) fn run_single_watch_refresh(
6422 store: &mut AtlasStore,
6423 plan: &ScanRuntimePlan,
6424 symbol_options: &SymbolBuildOptions,
6425) -> Result<WatchReport, CliError> {
6426 let control = index_work_control(symbol_options);
6427 run_single_watch_refresh_controlled(store, plan, symbol_options, &control)
6428}
6429
6430pub(crate) fn run_single_watch_refresh_controlled(
6432 store: &mut AtlasStore,
6433 plan: &ScanRuntimePlan,
6434 symbol_options: &SymbolBuildOptions,
6435 control: &IndexWorkControl,
6436) -> Result<WatchReport, CliError> {
6437 let bounded_control = bounded_index_work_control(control);
6438 let control = &bounded_control;
6439 control.check(IndexWorkStage::RepositoryTraversal)?;
6440 let current_plan = plan.reload_controlled(control)?;
6441 let last_refresh = refresh_index_controlled(store, ¤t_plan, symbol_options, control)?;
6442 Ok(WatchReport {
6443 mode: WATCH_MODE_ONCE.to_string(),
6444 cycles: 1,
6445 once: true,
6446 fallback_reason: None,
6447 text_index: last_refresh.text_index,
6448 structural_summaries: last_refresh.structural_summaries,
6449 last_symbols: last_refresh.symbols,
6450 })
6451}
6452
6453pub(crate) fn run_notify_watch_loop(
6455 store: &mut AtlasStore,
6456 plan: &ScanRuntimePlan,
6457 poll_seconds: u64,
6458 max_cycles: usize,
6459 symbol_options: &SymbolBuildOptions,
6460) -> Result<WatchReport, CliError> {
6461 let mut current_plan = plan.reload()?;
6462 let watch_root = current_plan
6463 .root
6464 .canonicalize()
6465 .map_err(|source| CliError::Io {
6466 path: current_plan.root.clone(),
6467 source,
6468 })?;
6469 let (sender, receiver) = mpsc::sync_channel(WATCH_EVENT_QUEUE_CAPACITY);
6470 let continuity_lost = Arc::new(AtomicBool::new(false));
6471 let callback_continuity_lost = Arc::clone(&continuity_lost);
6472 let mut watcher = RecommendedWatcher::new(
6473 move |result: notify::Result<Event>| {
6474 match sender.try_send(result) {
6475 Ok(()) => {}
6476 Err(TrySendError::Full(_result)) => {
6477 callback_continuity_lost.store(true, Ordering::Release);
6478 }
6479 Err(TrySendError::Disconnected(_result)) => {
6480 }
6482 }
6483 },
6484 Config::default(),
6485 )
6486 .map_err(|source| CliError::Watcher(source.to_string()))?;
6487 watcher
6488 .watch(&watch_root, RecursiveMode::Recursive)
6489 .map_err(|source| CliError::Watcher(source.to_string()))?;
6490 let debounce = Duration::from_secs(poll_seconds.max(1));
6491 let mut cycles = 0;
6492 let mut last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
6493 cycles += 1;
6494 while max_cycles == 0 || cycles < max_cycles {
6495 let changes = wait_for_index_event_with_continuity(
6496 &receiver,
6497 &watch_root,
6498 debounce,
6499 ¤t_plan.scan_options,
6500 &continuity_lost,
6501 )?;
6502 if changes.has_changes() {
6503 current_plan = plan.reload()?;
6504 last_refresh =
6505 refresh_index_for_changes(store, ¤t_plan, &changes, symbol_options)?;
6506 cycles += 1;
6507 }
6508 }
6509 Ok(WatchReport {
6510 mode: WATCH_MODE_NOTIFY.to_string(),
6511 cycles,
6512 once: false,
6513 fallback_reason: None,
6514 text_index: last_refresh.text_index,
6515 structural_summaries: last_refresh.structural_summaries,
6516 last_symbols: last_refresh.symbols,
6517 })
6518}
6519
6520fn wait_for_index_event_with_continuity(
6522 receiver: &mpsc::Receiver<notify::Result<Event>>,
6523 root: &Path,
6524 debounce: Duration,
6525 scan_options: &ScanOptions,
6526 continuity_lost: &AtomicBool,
6527) -> Result<WatchChangeSet, CliError> {
6528 let mut changes = notify_result_changes(
6529 root,
6530 scan_options,
6531 receiver.recv().map_err(|source| {
6532 CliError::Watcher(format!("watch event channel disconnected: {source}"))
6533 })?,
6534 )?;
6535 loop {
6536 match receiver.recv_timeout(debounce) {
6537 Ok(result) => {
6538 changes.merge(notify_result_changes(root, scan_options, result)?);
6539 }
6540 Err(RecvTimeoutError::Timeout) => break,
6541 Err(RecvTimeoutError::Disconnected) => {
6542 return Err(CliError::Watcher(
6543 "watch event channel disconnected".to_string(),
6544 ));
6545 }
6546 }
6547 }
6548 if continuity_lost.swap(false, Ordering::AcqRel) {
6549 changes.requires_full_scan = true;
6550 }
6551 Ok(changes)
6552}
6553
6554pub(crate) fn notify_result_changes(
6556 root: &Path,
6557 scan_options: &ScanOptions,
6558 result: notify::Result<Event>,
6559) -> Result<WatchChangeSet, CliError> {
6560 let event = result.map_err(|source| CliError::Watcher(source.to_string()))?;
6561 Ok(notify_event_changes(root, scan_options, &event))
6562}
6563
6564pub(crate) fn notify_event_changes(
6566 root: &Path,
6567 scan_options: &ScanOptions,
6568 event: &Event,
6569) -> WatchChangeSet {
6570 if !event_kind_affects_index(event.kind) {
6571 return WatchChangeSet::default();
6572 }
6573 let mut changes = WatchChangeSet {
6574 requires_full_scan: event.need_rescan(),
6575 paths: HashSet::new(),
6576 };
6577 for path in &event.paths {
6578 let candidate = absolute_watch_path(root, path);
6579 if watch_path_requires_full_scan(root, &candidate) {
6580 changes.requires_full_scan = true;
6581 changes.paths.insert(candidate);
6582 continue;
6583 }
6584 let Some(index_path) = normalized_watch_index_path(root, path, scan_options) else {
6585 continue;
6586 };
6587 if matches!(
6588 event.kind,
6589 EventKind::Modify(notify::event::ModifyKind::Name(_))
6590 ) || watch_path_requires_full_scan(root, &index_path)
6591 {
6592 changes.requires_full_scan = true;
6593 }
6594 changes.paths.insert(index_path);
6595 }
6596 changes
6597}
6598
6599pub(crate) fn event_kind_affects_index(kind: EventKind) -> bool {
6601 !matches!(kind, EventKind::Access(_))
6602}
6603
6604#[cfg(test)]
6606pub(crate) fn watch_path_affects_index(
6607 root: &Path,
6608 path: &Path,
6609 scan_options: &ScanOptions,
6610) -> bool {
6611 normalized_watch_index_path(root, path, scan_options).is_some()
6612}
6613
6614fn normalized_watch_index_path(
6616 root: &Path,
6617 path: &Path,
6618 scan_options: &ScanOptions,
6619) -> Option<PathBuf> {
6620 let candidate = absolute_watch_path(root, path);
6621 let relative = safe_watch_relative_path(root, &candidate)?;
6622 if relative == "." {
6623 return Some(root.to_path_buf());
6624 }
6625 let policy_path = if candidate.strip_prefix(root).is_ok() {
6626 candidate.clone()
6627 } else {
6628 match candidate.try_exists() {
6629 Ok(true) => candidate.clone(),
6630 Ok(false) => root.join(repo_path_to_native(&relative)),
6631 Err(_) => return None,
6632 }
6633 };
6634 let Ok(gitignore_ignored) = gitignore_excludes_path(root, &policy_path) else {
6636 return None;
6637 };
6638 if gitignore_ignored {
6639 return None;
6640 }
6641 if relative.split('/').any(|component| component == ".purpose")
6642 || scan_options.excludes_relative_path(&relative)
6643 {
6644 return None;
6645 }
6646 Some(candidate)
6647}
6648
6649fn safe_watch_relative_path(root: &Path, candidate: &Path) -> Option<String> {
6651 let relative = normalize_repo_path(root, candidate)
6652 .ok()
6653 .or_else(|| native_display_relative_path(root, candidate))?;
6654 valid_watch_relative_path(relative)
6655}
6656
6657fn native_display_relative_path(root: &Path, candidate: &Path) -> Option<String> {
6659 let root = normalize_native_path_display_str(root.to_str()?);
6660 let candidate = normalize_native_path_display_str(candidate.to_str()?);
6661 let root = if root == "/" {
6662 root.as_str()
6663 } else {
6664 root.trim_end_matches('/')
6665 };
6666 if candidate == root || cfg!(windows) && candidate.eq_ignore_ascii_case(root) {
6667 return Some(".".to_string());
6668 }
6669 let prefix = if root == "/" {
6670 "/".to_string()
6671 } else {
6672 format!("{root}/")
6673 };
6674 if let Some(relative) = candidate.strip_prefix(&prefix) {
6675 return Some(relative.to_string());
6676 }
6677 #[cfg(windows)]
6678 {
6679 let prefix_candidate = candidate.get(..prefix.len())?;
6680 if prefix_candidate.eq_ignore_ascii_case(&prefix) {
6681 return candidate.get(prefix.len()..).map(ToOwned::to_owned);
6682 }
6683 }
6684 None
6685}
6686
6687fn valid_watch_relative_path(relative: String) -> Option<String> {
6689 if relative == "." {
6690 return Some(relative);
6691 }
6692 if relative
6693 .split('/')
6694 .any(|component| component.is_empty() || component == "." || component == "..")
6695 {
6696 return None;
6697 }
6698 Some(relative)
6699}
6700
6701pub(crate) fn absolute_watch_path(root: &Path, path: &Path) -> PathBuf {
6703 if path.is_absolute() {
6704 path.to_path_buf()
6705 } else {
6706 root.join(path)
6707 }
6708}
6709
6710pub(crate) fn watch_path_requires_full_scan(root: &Path, path: &Path) -> bool {
6712 let Some(relative) = safe_watch_relative_path(root, path) else {
6713 return false;
6714 };
6715 if relative == "." {
6716 return true;
6717 }
6718 path.is_dir()
6719 || matches!(relative.rsplit('/').next(), Some(".gitignore" | ".ignore"))
6720 || relative == ".git"
6721 || relative.ends_with("/.git")
6722 || relative == ".git/info/exclude"
6723 || relative.ends_with("/.git/info/exclude")
6724 || matches!(
6725 relative.rsplit('/').next(),
6726 Some("tsconfig.json" | "jsconfig.json")
6727 )
6728 || index_policy_path(relative.as_str())
6729}
6730
6731fn index_policy_path(relative: &str) -> bool {
6733 CORE_INDEX_POLICY_PATHS.contains(&relative)
6734 || cfg!(feature = "optional-parser-supervisor") && {
6735 #[cfg(feature = "optional-parser-supervisor")]
6736 {
6737 relative == OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH
6738 }
6739 #[cfg(not(feature = "optional-parser-supervisor"))]
6740 {
6741 false
6742 }
6743 }
6744}
6745
6746pub(crate) fn run_polling_watch_loop(
6748 store: &mut AtlasStore,
6749 plan: &ScanRuntimePlan,
6750 poll_seconds: u64,
6751 max_cycles: usize,
6752 symbol_options: &SymbolBuildOptions,
6753 fallback_reason: Option<String>,
6754) -> Result<WatchReport, CliError> {
6755 let mut cycles = 0;
6756 let mut current_plan = plan.reload()?;
6757 let mut last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
6758 cycles += 1;
6759 while max_cycles == 0 || cycles < max_cycles {
6760 thread::sleep(Duration::from_secs(poll_seconds.max(1)));
6761 current_plan = plan.reload()?;
6762 last_refresh = refresh_index(store, ¤t_plan, symbol_options)?;
6763 cycles += 1;
6764 }
6765 Ok(WatchReport {
6766 mode: WATCH_MODE_POLLING.to_string(),
6767 cycles,
6768 once: false,
6769 fallback_reason,
6770 text_index: last_refresh.text_index,
6771 structural_summaries: last_refresh.structural_summaries,
6772 last_symbols: last_refresh.symbols,
6773 })
6774}
6775
6776pub(crate) struct IndexRefreshReport {
6778 pub(crate) text_index: TextIndexReport,
6780 pub(crate) structural_summaries: StructuralSummaryReport,
6782 symbols: SymbolBuildReport,
6784}
6785
6786pub(crate) fn refresh_index(
6788 store: &mut AtlasStore,
6789 plan: &ScanRuntimePlan,
6790 symbol_options: &SymbolBuildOptions,
6791) -> Result<IndexRefreshReport, CliError> {
6792 let control = index_work_control(symbol_options);
6793 refresh_index_controlled(store, plan, symbol_options, &control)
6794}
6795
6796pub(crate) fn refresh_index_controlled(
6798 store: &mut AtlasStore,
6799 plan: &ScanRuntimePlan,
6800 symbol_options: &SymbolBuildOptions,
6801 control: &IndexWorkControl,
6802) -> Result<IndexRefreshReport, CliError> {
6803 let bounded_control = bounded_index_work_control(control);
6804 let control = &bounded_control;
6805 let reuse_unchanged_symbols = publication_contract_matches(store, plan)?;
6806 if reuse_unchanged_symbols
6807 && detect_index_freshness_controlled(store, plan, ScanLimits::default(), control)?
6808 .delta
6809 .is_none()
6810 {
6811 graph_projection::cleanup_abandoned_repository_graph_staging(store, &plan.root, control)?;
6812 return Ok(empty_index_refresh_report(plan.text_options));
6813 }
6814 let batch = stage_full_index_publication(
6815 store,
6816 plan,
6817 symbol_options,
6818 reuse_unchanged_symbols,
6819 false,
6820 control,
6821 )?;
6822 revalidate_staged_publication_inputs_controlled(
6823 plan,
6824 batch.nodes.expected_nodes(),
6825 None,
6826 control,
6827 )?;
6828 if staged_full_refresh_is_unchanged(store, &batch)? {
6829 return Ok(empty_index_refresh_report(plan.text_options));
6830 }
6831 let outcome = publish_index_batch(store, batch, control)?;
6832 Ok(IndexRefreshReport {
6833 text_index: outcome.text_index,
6834 structural_summaries: outcome.structural_summaries,
6835 symbols: outcome.symbols,
6836 })
6837}
6838
6839fn staged_full_refresh_is_unchanged(
6841 store: &AtlasStore,
6842 batch: &IndexPublicationBatch,
6843) -> Result<bool, CliError> {
6844 let NodePublicationBatch::Full { nodes: expected } = &batch.nodes else {
6845 return Ok(false);
6846 };
6847 if batch.purpose_import.is_some() {
6848 return Ok(false);
6849 }
6850 let Some(publication) = store.index_publication()? else {
6851 return Ok(false);
6852 };
6853 if publication.state != IndexPublicationState::Complete
6854 || publication.generation != batch.base_generation
6855 || publication.contract_fingerprint.as_deref() != Some(batch.contract_fingerprint.as_str())
6856 {
6857 return Ok(false);
6858 }
6859 let current = store
6860 .load_nodes()?
6861 .into_iter()
6862 .map(|indexed| indexed.node)
6863 .collect::<Vec<_>>();
6864 Ok(current.len() == expected.len()
6865 && current
6866 .iter()
6867 .zip(expected)
6868 .all(|(current, expected)| same_indexed_source(current, expected)))
6869}
6870
6871fn empty_index_refresh_report(text_options: TextIndexOptions) -> IndexRefreshReport {
6873 IndexRefreshReport {
6874 text_index: empty_text_index_report(text_options),
6875 structural_summaries: StructuralSummaryReport::default(),
6876 symbols: empty_symbol_build_report(),
6877 }
6878}
6879
6880pub(crate) fn refresh_index_for_changes(
6882 store: &mut AtlasStore,
6883 plan: &ScanRuntimePlan,
6884 changes: &WatchChangeSet,
6885 symbol_options: &SymbolBuildOptions,
6886) -> Result<IndexRefreshReport, CliError> {
6887 let control = index_work_control(symbol_options);
6888 refresh_index_for_changes_controlled(store, plan, changes, symbol_options, &control)
6889}
6890
6891pub(crate) fn refresh_index_for_changes_controlled(
6893 store: &mut AtlasStore,
6894 plan: &ScanRuntimePlan,
6895 changes: &WatchChangeSet,
6896 symbol_options: &SymbolBuildOptions,
6897 control: &IndexWorkControl,
6898) -> Result<IndexRefreshReport, CliError> {
6899 let bounded_control = bounded_index_work_control(control);
6900 let control = &bounded_control;
6901 control.check(IndexWorkStage::RepositoryTraversal)?;
6902 if changes.requires_full_scan || !publication_contract_matches(store, plan)? {
6903 return refresh_index_controlled(store, plan, symbol_options, control);
6904 }
6905 if changes.paths.len() > MAX_INCREMENTAL_CHANGED_PATHS {
6906 return Err(IndexWorkFailure::resource_limit(
6907 IndexWorkStage::RepositoryTraversal,
6908 IndexWorkResource::Entries,
6909 MAX_INCREMENTAL_CHANGED_PATHS as u64,
6910 changes.paths.len() as u64,
6911 )
6912 .into());
6913 }
6914 let root = &plan.root;
6915 let base_generation = publication_base_generation(store)?;
6916 let baseline_nodes = store
6917 .load_nodes()?
6918 .into_iter()
6919 .map(|indexed| indexed.node)
6920 .collect::<Vec<_>>();
6921 let baseline_by_path = baseline_nodes
6922 .iter()
6923 .map(|node| (node.path.clone(), node))
6924 .collect::<HashMap<_, _>>();
6925 let mut nodes = Vec::new();
6926 let mut absent_paths = Vec::new();
6927 let mut source_bytes = 0_u64;
6928 let scan_policy = RootScanPolicy::discover(root, &plan.scan_options, control)
6929 .map_err(|source| source_inspection_error(root, source))?;
6930 for path in sorted_watch_paths(&changes.paths) {
6931 control.check(IndexWorkStage::RepositoryTraversal)?;
6932 match path.try_exists() {
6933 Ok(true) => {
6934 let remaining_source_bytes =
6935 MAX_INCREMENTAL_SOURCE_BYTES.saturating_sub(source_bytes);
6936 if let Some(node) = scan_path_with_policy_controlled(
6937 &scan_policy,
6938 &path,
6939 ScanLimits::new(1, remaining_source_bytes, 1),
6940 control,
6941 )
6942 .map_err(|source| source_inspection_error(root, source))?
6943 {
6944 source_bytes = source_bytes
6945 .checked_add(node.size_bytes.unwrap_or_default())
6946 .ok_or_else(|| {
6947 IndexWorkFailure::resource_limit(
6948 IndexWorkStage::SourceMetadata,
6949 IndexWorkResource::SourceBytes,
6950 MAX_INCREMENTAL_SOURCE_BYTES,
6951 u64::MAX,
6952 )
6953 })?;
6954 if source_bytes > MAX_INCREMENTAL_SOURCE_BYTES {
6955 return Err(IndexWorkFailure::resource_limit(
6956 IndexWorkStage::SourceMetadata,
6957 IndexWorkResource::SourceBytes,
6958 MAX_INCREMENTAL_SOURCE_BYTES,
6959 source_bytes,
6960 )
6961 .into());
6962 }
6963 nodes.push(node);
6964 } else if let Some(path_key) = normalized_deleted_path(root, &path)? {
6965 absent_paths.push(path_key);
6966 }
6967 }
6968 Ok(false) => {
6969 if let Some(path_key) = normalized_deleted_path(root, &path)? {
6970 absent_paths.push(path_key);
6971 }
6972 }
6973 Err(source) => {
6974 return Err(CliError::VerificationIncomplete(Box::new(
6975 IndexVerificationIncomplete {
6976 project_root: normalize_native_path_display(root),
6977 status: IndexReadStatus::VerificationIncomplete,
6978 reason: IndexVerificationReason::SourceInspectionFailed,
6979 scope: IndexRefreshScope::Full,
6980 message: format!("failed to inspect '{}': {source}", path.display()),
6981 },
6982 )));
6983 }
6984 }
6985 }
6986 absent_paths.sort();
6987 absent_paths.dedup();
6988 let candidate_paths = nodes
6989 .iter()
6990 .map(|node| node.path.clone())
6991 .chain(absent_paths.iter().cloned())
6992 .collect::<HashSet<_>>();
6993 let mut sorted_candidate_paths = candidate_paths.into_iter().collect::<Vec<_>>();
6994 sorted_candidate_paths.sort();
6995 let existing_nodes = sorted_candidate_paths
6996 .iter()
6997 .filter_map(|path| {
6998 baseline_by_path
6999 .get(path)
7000 .map(|node| (path.clone(), (*node).clone()))
7001 })
7002 .collect::<HashMap<_, _>>();
7003 if absent_paths.iter().any(|path| {
7004 existing_nodes
7005 .get(path)
7006 .is_some_and(|node| node.kind == NodeKind::Folder)
7007 }) {
7008 return refresh_index_controlled(store, plan, symbol_options, control);
7009 }
7010 graph_projection::cleanup_abandoned_repository_graph_staging(store, root, control)?;
7011 let direct_graph_paths = nodes
7012 .iter()
7013 .filter(|node| node.kind == NodeKind::File)
7014 .map(|node| node.path.clone())
7015 .chain(absent_paths.iter().cloned())
7016 .collect::<BTreeSet<_>>();
7017 nodes.retain(|node| {
7018 existing_nodes
7019 .get(&node.path)
7020 .is_none_or(|indexed| !same_indexed_source(node, indexed))
7021 });
7022 absent_paths.retain(|path| existing_nodes.contains_key(path));
7023 if nodes.is_empty() && absent_paths.is_empty() {
7024 revalidate_staged_publication_inputs_controlled(plan, &baseline_nodes, None, control)?;
7025 return Ok(empty_index_refresh_report(plan.text_options));
7026 }
7027 drop(existing_nodes);
7028 drop(baseline_by_path);
7029 let changed_paths = nodes
7030 .iter()
7031 .map(|node| node.path.clone())
7032 .chain(absent_paths.iter().cloned())
7033 .collect::<HashSet<_>>();
7034 let previous_hashes = indexed_file_hashes_for_paths(store, &changed_paths)?;
7035 let mut text_paths = changed_paths.iter().cloned().collect::<Vec<_>>();
7036 text_paths.sort();
7037 let text =
7038 stage_text_index_for_changed_paths_controlled(root, &nodes, plan.text_options, control)?;
7039 let protected_purpose_paths = protected_purpose_paths(&nodes, None);
7040 let target_paths = nodes
7041 .iter()
7042 .filter(|node| node.kind == NodeKind::File)
7043 .map(|node| node.path.clone())
7044 .collect::<HashSet<_>>();
7045 let expected_nodes = expected_nodes_after_incremental(baseline_nodes, &nodes, &absent_paths);
7046 let contract_fingerprint = plan.publication_contract_fingerprint();
7047 let retained_before_symbols = staged_publication_identity_bytes(root, &contract_fingerprint)
7048 .saturating_add(staged_string_bytes(&text_paths))
7049 .saturating_add(staged_string_bytes(&absent_paths))
7050 .saturating_add(staged_node_bytes(&expected_nodes))
7051 .saturating_add(staged_node_bytes(&nodes))
7052 .saturating_add(staged_text_bytes(&text));
7053 let symbol_limits = symbol_limits_with_remaining_staging_bytes(retained_before_symbols)?;
7054 let symbols = stage_symbols_for_nodes_with_limits(
7055 store,
7056 root,
7057 #[cfg(feature = "optional-parser-supervisor")]
7058 &plan.optional_parser_selection,
7059 &nodes,
7060 symbol_options,
7061 Some(&previous_hashes),
7062 Some(&target_paths),
7063 &protected_purpose_paths,
7064 control,
7065 symbol_limits,
7066 )?;
7067 let graph = graph_projection::stage_incremental_repository_graph(
7068 store,
7069 root,
7070 base_generation,
7071 &expected_nodes,
7072 &direct_graph_paths.into_iter().collect::<Vec<_>>(),
7073 &symbols,
7074 control,
7075 )?;
7076 let structural_summaries = stage_structural_summaries_for_nodes_controlled(
7077 store,
7078 &nodes,
7079 &text.rows,
7080 Some(&symbols),
7081 &protected_purpose_paths,
7082 symbol_options.effective_workers(),
7083 control,
7084 )?;
7085 enforce_publication_staging_budget(
7086 retained_before_symbols
7087 .saturating_add(symbols.retained_bytes)
7088 .saturating_add(graph.retained_bytes())
7089 .saturating_add(structural_summaries.retained_bytes),
7090 )?;
7091 let batch = IndexPublicationBatch {
7092 base_generation,
7093 contract_fingerprint,
7094 root: root.clone(),
7095 nodes: NodePublicationBatch::Incremental {
7096 nodes,
7097 absent_paths,
7098 expected_nodes,
7099 },
7100 purpose_import: None,
7101 text_paths,
7102 text,
7103 symbols,
7104 graph,
7105 structural_summaries,
7106 };
7107 revalidate_staged_publication_inputs_controlled(
7108 plan,
7109 batch.nodes.expected_nodes(),
7110 None,
7111 control,
7112 )?;
7113 let outcome = publish_index_batch(store, batch, control)?;
7114 Ok(IndexRefreshReport {
7115 text_index: outcome.text_index,
7116 structural_summaries: outcome.structural_summaries,
7117 symbols: outcome.symbols,
7118 })
7119}
7120
7121pub(crate) fn seed_builtin_projectatlas_purposes(
7123 store: &AtlasStore,
7124 nodes: &[Node],
7125) -> Result<(), CliError> {
7126 let indexed_paths = nodes
7127 .iter()
7128 .map(|node| node.path.as_str())
7129 .collect::<HashSet<_>>();
7130 for (path, purpose) in BUILTIN_PROJECTATLAS_PURPOSES {
7131 if !indexed_paths.contains(path) {
7132 continue;
7133 }
7134 let Some(indexed) = store.load_node_by_path(path)? else {
7135 continue;
7136 };
7137 if !matches!(
7138 indexed.purpose.status,
7139 PurposeStatus::Approved | PurposeStatus::Stale
7140 ) {
7141 store.set_purpose(path, purpose, PurposeSource::Imported)?;
7142 }
7143 }
7144 Ok(())
7145}
7146
7147#[cfg(test)]
7149pub(crate) fn refresh_structural_summaries_for_nodes(
7150 store: &mut AtlasStore,
7151 nodes: &[Node],
7152 text_rows: &[TextIndexRow],
7153) -> Result<StructuralSummaryReport, CliError> {
7154 let control = standalone_index_work_control();
7155 refresh_structural_summaries_for_nodes_controlled(store, nodes, text_rows, &control)
7156}
7157
7158#[cfg(test)]
7160fn refresh_structural_summaries_for_nodes_controlled(
7161 store: &mut AtlasStore,
7162 nodes: &[Node],
7163 text_rows: &[TextIndexRow],
7164 control: &IndexWorkControl,
7165) -> Result<StructuralSummaryReport, CliError> {
7166 let staged = stage_structural_summaries_for_nodes_controlled(
7167 store,
7168 nodes,
7169 text_rows,
7170 None,
7171 &HashSet::new(),
7172 2,
7173 control,
7174 )?;
7175 apply_structural_summary_stage(store, &staged, control)?;
7176 Ok(staged.report)
7177}
7178
7179fn stage_structural_summaries_for_nodes_controlled(
7181 store: &AtlasStore,
7182 nodes: &[Node],
7183 text_rows: &[TextIndexRow],
7184 symbols: Option<&SymbolBuildStage>,
7185 protected_purpose_paths: &HashSet<String>,
7186 max_workers: usize,
7187 control: &IndexWorkControl,
7188) -> Result<StructuralSummaryStage, CliError> {
7189 control.check(IndexWorkStage::TextIndex)?;
7190 let candidates = nodes
7191 .iter()
7192 .filter(|node| node.kind == NodeKind::File)
7193 .filter(|node| is_structural_summary_candidate(&node.path, node.language.as_deref()))
7194 .collect::<Vec<_>>();
7195 if candidates.is_empty() {
7196 return Ok(StructuralSummaryStage {
7197 report: StructuralSummaryReport::default(),
7198 changes: Vec::new(),
7199 retained_bytes: 0,
7200 });
7201 }
7202 let paths = candidates
7203 .iter()
7204 .map(|node| node.path.clone())
7205 .collect::<Vec<_>>();
7206 let indexed_nodes = store
7207 .load_nodes_by_paths(&paths)?
7208 .into_iter()
7209 .map(|indexed| (indexed.node.path.clone(), indexed))
7210 .collect::<HashMap<_, _>>();
7211 let symbol_counts = store.symbol_counts_for_paths(&paths)?;
7212 let text_by_path = text_rows
7213 .iter()
7214 .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
7215 .collect::<HashMap<_, _>>();
7216 let reason_by_path = text_rows
7217 .iter()
7218 .map(|row| (row.path.as_str(), row.reason))
7219 .collect::<HashMap<_, _>>();
7220 let mut staged_symbol_counts = HashMap::new();
7221 let mut staged_symbol_summaries = HashMap::new();
7222 let mut staged_structural_summaries = HashMap::new();
7223 if let Some(symbols) = symbols {
7224 for change in &symbols.changes {
7225 match change {
7226 SymbolProjectionChange::Parsed(parsed) => {
7227 staged_symbol_counts.insert(parsed.path.as_str(), parsed.graph.symbols.len());
7228 staged_symbol_summaries.insert(parsed.path.as_str(), parsed.summary.as_str());
7229 if parsed.summary_is_structural {
7230 staged_structural_summaries
7231 .insert(parsed.path.as_str(), parsed.purpose_suggestion.is_some());
7232 }
7233 }
7234 SymbolProjectionChange::Clear { path, .. } => {
7235 staged_symbol_counts.insert(path.as_str(), 0);
7236 }
7237 }
7238 }
7239 }
7240 let mut report = StructuralSummaryReport {
7241 candidates: paths.len(),
7242 ..StructuralSummaryReport::default()
7243 };
7244 let worker_count = worker_count_for_work(candidates.len(), max_workers);
7245 let pool = ThreadPoolBuilder::new()
7246 .num_threads(worker_count)
7247 .build()
7248 .map_err(|source| {
7249 CliError::InvalidInput(format!("structural summary worker pool failed: {source}"))
7250 })?;
7251 let derivations = pool.install(|| {
7252 candidates
7253 .par_iter()
7254 .map(|node| -> Result<StructuralSummaryDerivation, CliError> {
7255 control.check(IndexWorkStage::TextIndex)?;
7256 let existing = indexed_nodes.get(&node.path);
7257 if reason_by_path.get(node.path.as_str()) == Some(&TextIndexSkipReason::TooLarge)
7258 || node
7259 .size_bytes
7260 .is_some_and(|size_bytes| size_bytes > MAX_SYMBOL_FILE_BYTES)
7261 {
7262 return Ok(StructuralSummaryDerivation {
7263 change: Some(StructuralSummaryChange::Clear {
7264 path: node.path.clone(),
7265 }),
7266 cleared: 1,
7267 too_large: 1,
7268 retained_bytes: node.path.len() as u64,
7269 ..StructuralSummaryDerivation::default()
7270 });
7271 }
7272 let Some(text) = text_by_path.get(node.path.as_str()) else {
7273 return Ok(StructuralSummaryDerivation {
7274 change: Some(StructuralSummaryChange::Clear {
7275 path: node.path.clone(),
7276 }),
7277 cleared: 1,
7278 binary_or_non_utf8: usize::from(
7279 reason_by_path.get(node.path.as_str())
7280 == Some(&TextIndexSkipReason::BinaryOrNonUtf8),
7281 ),
7282 retained_bytes: node.path.len() as u64,
7283 ..StructuralSummaryDerivation::default()
7284 });
7285 };
7286 if let Some(purpose_suggested) = staged_structural_summaries.get(node.path.as_str())
7287 {
7288 return Ok(StructuralSummaryDerivation {
7289 summarized: 1,
7290 purpose_suggestions: usize::from(*purpose_suggested),
7291 ..StructuralSummaryDerivation::default()
7292 });
7293 }
7294 let symbol_count = staged_symbol_counts
7295 .get(node.path.as_str())
7296 .copied()
7297 .or_else(|| symbol_counts.get(node.path.as_str()).copied())
7298 .unwrap_or_default();
7299 let effective_summary = staged_symbol_summaries
7300 .get(node.path.as_str())
7301 .copied()
7302 .or_else(|| existing.and_then(|indexed| indexed.summary.as_deref()));
7303 if symbol_count > 0
7304 && effective_summary.is_some_and(|summary| {
7305 !summary.trim().is_empty() && !is_scanner_fallback_summary(summary)
7306 })
7307 {
7308 return Ok(StructuralSummaryDerivation::default());
7309 }
7310 let Some(summary) = structural_summary_for_path(
7311 &node.path,
7312 node.language.as_deref(),
7313 &text.content,
7314 ) else {
7315 return Ok(StructuralSummaryDerivation {
7316 change: Some(StructuralSummaryChange::Clear {
7317 path: node.path.clone(),
7318 }),
7319 cleared: 1,
7320 retained_bytes: node.path.len() as u64,
7321 ..StructuralSummaryDerivation::default()
7322 });
7323 };
7324 let purpose_needs_suggestion = !protected_purpose_paths.contains(&node.path)
7325 && existing.is_none_or(|indexed| {
7326 matches!(
7327 indexed.purpose.status,
7328 PurposeStatus::Missing | PurposeStatus::Suggested
7329 )
7330 });
7331 let purpose_suggestion =
7332 purpose_needs_suggestion.then(|| suggest_file_purpose(&node.path, &summary));
7333 let purpose_suggestions = usize::from(purpose_suggestion.is_some());
7334 let retained_bytes = (node.path.len() as u64)
7335 .saturating_add(summary.len() as u64)
7336 .saturating_add(
7337 purpose_suggestion
7338 .as_ref()
7339 .map_or(0, |suggestion| suggestion.len() as u64),
7340 );
7341 control.check(IndexWorkStage::TextIndex)?;
7342 Ok(StructuralSummaryDerivation {
7343 change: Some(StructuralSummaryChange::Set {
7344 path: node.path.clone(),
7345 summary,
7346 purpose_suggestion,
7347 }),
7348 summarized: 1,
7349 purpose_suggestions,
7350 retained_bytes,
7351 ..StructuralSummaryDerivation::default()
7352 })
7353 })
7354 .collect::<Result<Vec<_>, CliError>>()
7355 })?;
7356 let mut changes = Vec::new();
7357 let mut retained_bytes = 0_u64;
7358 for derivation in derivations {
7359 report.summarized += derivation.summarized;
7360 report.cleared += derivation.cleared;
7361 report.too_large += derivation.too_large;
7362 report.binary_or_non_utf8 += derivation.binary_or_non_utf8;
7363 report.purpose_suggestions += derivation.purpose_suggestions;
7364 retained_bytes = retained_bytes.saturating_add(derivation.retained_bytes);
7365 if let Some(change) = derivation.change {
7366 changes.push(change);
7367 }
7368 }
7369 Ok(StructuralSummaryStage {
7370 report,
7371 changes,
7372 retained_bytes,
7373 })
7374}
7375
7376fn apply_structural_summary_stage(
7378 store: &mut AtlasStore,
7379 staged: &StructuralSummaryStage,
7380 control: &IndexWorkControl,
7381) -> Result<(), CliError> {
7382 for change in &staged.changes {
7383 control.check(IndexWorkStage::Publication)?;
7384 match change {
7385 StructuralSummaryChange::Set {
7386 path,
7387 summary,
7388 purpose_suggestion,
7389 } => {
7390 store.set_node_summary(path, summary)?;
7391 if let Some(suggestion) = purpose_suggestion.as_deref() {
7392 store.set_suggested_purpose(path, suggestion)?;
7393 }
7394 }
7395 StructuralSummaryChange::Clear { path } => store.clear_node_summary(path)?,
7396 }
7397 }
7398 control.check(IndexWorkStage::Publication)?;
7399 Ok(())
7400}
7401
7402#[cfg(test)]
7404pub(crate) fn refresh_text_index_for_nodes(
7405 store: &mut AtlasStore,
7406 root: &Path,
7407 nodes: &[Node],
7408 options: TextIndexOptions,
7409) -> Result<TextIndexReport, CliError> {
7410 let control = standalone_index_work_control();
7411 Ok(
7412 refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)?
7413 .report,
7414 )
7415}
7416
7417#[cfg(test)]
7419pub(crate) fn refresh_text_index_for_nodes_with_rows(
7420 store: &mut AtlasStore,
7421 root: &Path,
7422 nodes: &[Node],
7423 options: TextIndexOptions,
7424) -> Result<TextIndexRefresh, CliError> {
7425 let control = standalone_index_work_control();
7426 refresh_text_index_for_nodes_with_rows_controlled(store, root, nodes, options, &control)
7427}
7428
7429#[cfg(test)]
7431fn refresh_text_index_for_nodes_with_rows_controlled(
7432 store: &mut AtlasStore,
7433 root: &Path,
7434 nodes: &[Node],
7435 options: TextIndexOptions,
7436 control: &IndexWorkControl,
7437) -> Result<TextIndexRefresh, CliError> {
7438 let file_paths = nodes
7439 .iter()
7440 .filter(|node| node.kind == NodeKind::File)
7441 .map(|node| node.path.clone())
7442 .collect::<Vec<_>>();
7443 refresh_text_index_for_changed_paths_with_rows_controlled(
7444 store,
7445 root,
7446 &file_paths,
7447 nodes,
7448 options,
7449 control,
7450 )
7451}
7452
7453#[cfg(test)]
7455fn refresh_text_index_for_changed_paths_with_rows_controlled(
7456 store: &mut AtlasStore,
7457 root: &Path,
7458 considered_paths: &[String],
7459 nodes: &[Node],
7460 options: TextIndexOptions,
7461 control: &IndexWorkControl,
7462) -> Result<TextIndexRefresh, CliError> {
7463 let staged = stage_text_index_for_changed_paths_controlled(root, nodes, options, control)?;
7464 apply_text_index_stage(store, considered_paths, &staged, control)?;
7465 Ok(staged)
7466}
7467
7468fn stage_text_index_for_changed_paths_controlled(
7470 root: &Path,
7471 nodes: &[Node],
7472 options: TextIndexOptions,
7473 control: &IndexWorkControl,
7474) -> Result<TextIndexRefresh, CliError> {
7475 control.check(IndexWorkStage::TextIndex)?;
7476 let text_rows = indexed_file_texts_for_nodes_controlled(root, nodes, options, control)?;
7477 let indexed = text_rows.iter().filter(|row| row.text.is_some()).count();
7478 let indexed_bytes = text_rows
7479 .iter()
7480 .filter_map(|row| row.text.as_ref())
7481 .map(|text| text.byte_count)
7482 .fold(0usize, usize::saturating_add);
7483 let file_candidates = nodes
7484 .iter()
7485 .filter(|node| node.kind == NodeKind::File)
7486 .count();
7487 let binary_or_non_utf8 = text_rows
7488 .iter()
7489 .filter(|row| row.reason == TextIndexSkipReason::BinaryOrNonUtf8)
7490 .count();
7491 let too_large = text_rows
7492 .iter()
7493 .filter(|row| row.reason == TextIndexSkipReason::TooLarge)
7494 .count();
7495 let report = TextIndexReport {
7496 candidates: file_candidates,
7497 indexed,
7498 binary_or_non_utf8,
7499 too_large,
7500 skipped: file_candidates.saturating_sub(indexed),
7501 max_bytes: options.max_bytes,
7502 bytes: indexed_bytes,
7503 };
7504 control.check(IndexWorkStage::TextIndex)?;
7505 Ok(TextIndexRefresh {
7506 report,
7507 rows: text_rows,
7508 })
7509}
7510
7511fn apply_text_index_stage(
7513 store: &mut AtlasStore,
7514 considered_paths: &[String],
7515 staged: &TextIndexRefresh,
7516 control: &IndexWorkControl,
7517) -> Result<(), CliError> {
7518 let text_by_path = staged
7519 .rows
7520 .iter()
7521 .filter_map(|row| row.text.as_ref().map(|text| (text.path.as_str(), text)))
7522 .collect::<HashMap<_, _>>();
7523 for paths in considered_paths.chunks(PUBLICATION_TEXT_BATCH_SIZE) {
7524 control.check(IndexWorkStage::Publication)?;
7525 store.replace_file_texts_for_paths(
7526 paths,
7527 paths
7528 .iter()
7529 .filter_map(|path| text_by_path.get(path.as_str()).copied()),
7530 )?;
7531 }
7532 control.check(IndexWorkStage::Publication)?;
7533 Ok(())
7534}
7535
7536#[cfg(test)]
7538pub(crate) fn indexed_file_texts_for_nodes(
7539 root: &Path,
7540 nodes: &[Node],
7541 options: TextIndexOptions,
7542) -> Result<Vec<TextIndexRow>, CliError> {
7543 let control = standalone_index_work_control();
7544 indexed_file_texts_for_nodes_controlled(root, nodes, options, &control)
7545}
7546
7547fn indexed_file_texts_for_nodes_controlled(
7549 root: &Path,
7550 nodes: &[Node],
7551 options: TextIndexOptions,
7552 control: &IndexWorkControl,
7553) -> Result<Vec<TextIndexRow>, CliError> {
7554 indexed_file_texts_for_nodes_with_limit(root, nodes, options, MAX_STAGED_TEXT_BYTES, control)
7555}
7556
7557fn indexed_file_texts_for_nodes_with_limit(
7559 root: &Path,
7560 nodes: &[Node],
7561 options: TextIndexOptions,
7562 max_staged_bytes: u64,
7563 control: &IndexWorkControl,
7564) -> Result<Vec<TextIndexRow>, CliError> {
7565 let mut rows = Vec::new();
7566 let mut staged_bytes = 0_u64;
7567 for node in nodes.iter().filter(|node| node.kind == NodeKind::File) {
7568 control.check(IndexWorkStage::TextIndex)?;
7569 if node
7570 .size_bytes
7571 .is_some_and(|size_bytes| size_bytes > options.max_bytes)
7572 {
7573 rows.push(TextIndexRow {
7574 path: node.path.clone(),
7575 text: None,
7576 reason: TextIndexSkipReason::TooLarge,
7577 });
7578 continue;
7579 }
7580 let remaining_staged_bytes = max_staged_bytes.saturating_sub(staged_bytes);
7581 if node
7582 .size_bytes
7583 .is_some_and(|size_bytes| size_bytes > remaining_staged_bytes)
7584 {
7585 return Err(IndexWorkFailure::resource_limit(
7586 IndexWorkStage::TextIndex,
7587 IndexWorkResource::TextBytes,
7588 max_staged_bytes,
7589 staged_bytes.saturating_add(node.size_bytes.unwrap_or_default()),
7590 )
7591 .into());
7592 }
7593 let native_path = root.join(repo_path_to_native(&node.path));
7594 let read_limit = options.max_bytes.min(remaining_staged_bytes);
7595 let aggregate_limit_is_narrower = remaining_staged_bytes <= options.max_bytes;
7596 let bytes = match read_source_bytes_controlled(
7597 &native_path,
7598 read_limit,
7599 IndexWorkStage::TextIndex,
7600 control,
7601 ) {
7602 Ok(bytes) => bytes,
7603 Err(SourceReadFailure::Io(source)) => {
7604 return Err(CliError::Io {
7605 path: native_path,
7606 source,
7607 });
7608 }
7609 Err(SourceReadFailure::IndexWork(failure)) => return Err(failure.into()),
7610 Err(SourceReadFailure::LimitExceeded { observed }) if aggregate_limit_is_narrower => {
7611 return Err(IndexWorkFailure::resource_limit(
7612 IndexWorkStage::TextIndex,
7613 IndexWorkResource::TextBytes,
7614 max_staged_bytes,
7615 staged_bytes.saturating_add(observed),
7616 )
7617 .into());
7618 }
7619 Err(SourceReadFailure::LimitExceeded { .. }) => {
7620 return Err(source_changed_during_derivation(root, &node.path));
7621 }
7622 };
7623 control.check(IndexWorkStage::TextIndex)?;
7624 let current_hash = blake3::hash(&bytes).to_hex().to_string();
7625 if node.content_hash.as_deref() != Some(current_hash.as_str()) {
7626 return Err(source_changed_during_derivation(root, &node.path));
7627 }
7628 let Ok(content) = String::from_utf8(bytes) else {
7629 rows.push(TextIndexRow {
7630 path: node.path.clone(),
7631 text: None,
7632 reason: TextIndexSkipReason::BinaryOrNonUtf8,
7633 });
7634 continue;
7635 };
7636 staged_bytes = staged_bytes.saturating_add(content.len() as u64);
7637 rows.push(TextIndexRow {
7638 path: node.path.clone(),
7639 reason: TextIndexSkipReason::Indexed,
7640 text: Some(IndexedFileText {
7641 path: node.path.clone(),
7642 content_hash: node.content_hash.clone(),
7643 byte_count: content.len(),
7644 line_count: content.lines().count(),
7645 content,
7646 }),
7647 });
7648 }
7649 Ok(rows)
7650}
7651
7652pub(crate) fn indexed_file_hashes(store: &AtlasStore) -> Result<HashMap<String, String>, CliError> {
7654 Ok(store
7655 .load_nodes()?
7656 .into_iter()
7657 .filter(|node| node.node.kind == NodeKind::File)
7658 .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
7659 .collect::<HashMap<_, _>>())
7660}
7661
7662pub(crate) fn indexed_file_hashes_for_paths(
7664 store: &AtlasStore,
7665 paths: &HashSet<String>,
7666) -> Result<HashMap<String, String>, CliError> {
7667 let mut sorted_paths = paths.iter().cloned().collect::<Vec<_>>();
7668 sorted_paths.sort();
7669 Ok(store
7670 .load_nodes_by_paths(&sorted_paths)?
7671 .into_iter()
7672 .filter(|node| node.node.kind == NodeKind::File)
7673 .filter_map(|node| node.node.content_hash.map(|hash| (node.node.path, hash)))
7674 .collect::<HashMap<_, _>>())
7675}
7676
7677pub(crate) fn sorted_watch_paths(paths: &HashSet<PathBuf>) -> Vec<PathBuf> {
7679 let mut paths = paths.iter().cloned().collect::<Vec<_>>();
7680 paths.sort();
7681 paths
7682}
7683
7684pub(crate) fn normalized_deleted_path(
7686 root: &Path,
7687 path: &Path,
7688) -> Result<Option<String>, CliError> {
7689 match normalize_repo_path(root, path) {
7690 Ok(path) => Ok(valid_watch_relative_path(path)),
7691 Err(projectatlas_core::CoreError::PathOutsideRoot { .. }) => {
7692 Ok(native_display_relative_path(root, path).and_then(valid_watch_relative_path))
7693 }
7694 Err(source) => Err(CliError::InvalidInput(source.to_string())),
7695 }
7696}
7697
7698pub(crate) fn strip_legacy_purpose(
7700 root: &Path,
7701 config_path: Option<&Path>,
7702 apply: bool,
7703 dry_run: bool,
7704 strip_source_headers: bool,
7705) -> Result<LegacyPurposeReport, CliError> {
7706 let root = root.canonicalize().map_err(|source| CliError::Io {
7707 path: root.to_path_buf(),
7708 source,
7709 })?;
7710 let scan_options = scan_options_for_root(config_path, &root)?;
7711 let nodes = scan_repo(&root, &scan_options)?;
7712 let effective_dry_run = dry_run || !apply;
7713 let purpose_files = indexed_purpose_files(&root, &nodes);
7714 let mut removed = 0;
7715 if !effective_dry_run {
7716 for path in &purpose_files {
7717 let native = root.join(repo_path_to_native(path));
7718 fs::remove_file(&native).map_err(|source| CliError::Io {
7719 path: native,
7720 source,
7721 })?;
7722 removed += 1;
7723 }
7724 }
7725 let source_header_candidates = if strip_source_headers {
7726 purpose_header_candidates(&root, &nodes)?
7727 } else {
7728 Vec::new()
7729 };
7730 Ok(LegacyPurposeReport {
7731 applied: !effective_dry_run,
7732 purpose_files_found: purpose_files.len(),
7733 purpose_files_removed: removed,
7734 source_header_candidates,
7735 purpose_files,
7736 })
7737}
7738
7739pub(crate) fn indexed_purpose_files(root: &Path, nodes: &[Node]) -> Vec<String> {
7741 let mut purpose_files = Vec::new();
7742 for node in nodes.iter().filter(|node| node.kind == NodeKind::Folder) {
7743 let relative = if node.path == "." {
7744 ".purpose".to_string()
7745 } else {
7746 format!("{}/.purpose", node.path)
7747 };
7748 let native = root.join(repo_path_to_native(&relative));
7749 if native.exists() {
7750 purpose_files.push(relative);
7751 }
7752 }
7753 purpose_files.sort();
7754 purpose_files
7755}
7756
7757pub(crate) fn purpose_header_candidates(
7759 root: &Path,
7760 nodes: &[Node],
7761) -> Result<Vec<String>, CliError> {
7762 let mut candidates = Vec::new();
7763 for node in nodes
7764 .iter()
7765 .filter(|node| node.kind == NodeKind::File)
7766 .filter(|node| is_symbol_candidate(&node.path, node.language.as_deref()))
7767 {
7768 let path = root.join(repo_path_to_native(&node.path));
7769 let content = fs::read_to_string(&path).map_err(|source| CliError::Io { path, source })?;
7770 if content
7771 .lines()
7772 .take(3)
7773 .any(|line| line.trim_start().contains("Purpose:"))
7774 {
7775 candidates.push(node.path.clone());
7776 }
7777 }
7778 Ok(candidates)
7779}
7780
7781#[cfg(test)]
7782mod tests {
7783 use super::*;
7784 use projectatlas_core::graph::{GraphRelationKind, RelationResolution, RepositoryNodePath};
7785 use projectatlas_db::RepositoryGraphRelationQuery;
7786 use std::error::Error;
7787 use std::fmt::Debug;
7788
7789 #[test]
7790 fn worker_pools_respect_work_cardinality_and_runtime_ceiling() {
7791 for (work_items, max_workers, expected) in [
7792 (0, 16, 0),
7793 (1, 16, 1),
7794 (8, 16, 8),
7795 (64, 16, 16),
7796 (64, usize::MAX, INDEX_WORKER_SAFE_CEILING),
7797 (8, 0, 1),
7798 ] {
7799 assert_eq!(
7800 worker_count_for_work(work_items, max_workers),
7801 expected,
7802 "work_items={work_items}, max_workers={max_workers}"
7803 );
7804 }
7805 }
7806
7807 #[test]
7808 fn settings_publication_identity_rejects_mixed_snapshots() {
7809 let fingerprint = "a".repeat(64);
7810 let diagnostic = DatabasePublicationReport {
7811 state: IndexPublicationState::Complete,
7812 contract_fingerprint: Some(fingerprint.clone()),
7813 contract_fingerprint_state: DatabasePublicationContractState::Valid,
7814 generation: IndexGeneration::new(4),
7815 };
7816 let matching = IndexPublication {
7817 state: IndexPublicationState::Complete,
7818 contract_fingerprint: Some(fingerprint),
7819 generation: IndexGeneration::new(4),
7820 };
7821 assert!(settings_publication_matches(
7822 Some(&diagnostic),
7823 Some(&matching)
7824 ));
7825
7826 let next_generation = IndexPublication {
7827 generation: IndexGeneration::new(5),
7828 ..matching.clone()
7829 };
7830 assert!(!settings_publication_matches(
7831 Some(&diagnostic),
7832 Some(&next_generation)
7833 ));
7834
7835 let invalid = DatabasePublicationReport {
7836 contract_fingerprint: None,
7837 contract_fingerprint_state: DatabasePublicationContractState::Invalid,
7838 ..diagnostic
7839 };
7840 assert!(!settings_publication_matches(
7841 Some(&invalid),
7842 Some(&matching)
7843 ));
7844 }
7845
7846 #[test]
7847 fn supplied_language_controls_symbol_candidate_owner() {
7848 for path in ["Cargo.toml", "src/App.vue", "scripts/Get-Atlas.ps1"] {
7849 assert!(!is_symbol_candidate(path, Some("toon")), "{path}");
7850 }
7851 assert!(is_symbol_candidate("data/report.toon", Some("rust")));
7852 assert!(is_symbol_candidate(
7853 "data/report.toon",
7854 Some("cargo-manifest")
7855 ));
7856 assert!(is_symbol_candidate("data/report.toon", Some("vue")));
7857 assert!(is_symbol_candidate("data/report.toon", Some("powershell")));
7858 assert!(!is_symbol_candidate("data/report.toon", Some("toon")));
7859 }
7860
7861 #[test]
7862 fn optional_catalog_symbol_work_requires_effective_admission() {
7863 assert!(is_symbol_candidate("scripts/report.awk", Some("awk")));
7864 assert!(!is_symbol_candidate_for_admission(
7865 "scripts/report.awk",
7866 Some("awk"),
7867 false,
7868 ));
7869 assert!(is_symbol_candidate_for_admission(
7870 "scripts/report.awk",
7871 Some("awk"),
7872 true,
7873 ));
7874 assert!(is_symbol_candidate_for_admission(
7875 "src/lib.rs",
7876 Some("rust"),
7877 false,
7878 ));
7879 }
7880
7881 #[test]
7882 fn missing_language_preserves_specialized_symbol_candidate_inference() {
7883 for path in [
7884 "Cargo.toml",
7885 "Cargo.lock",
7886 "src/App.vue",
7887 "src/App.VUE",
7888 "scripts/Get-Atlas.ps1",
7889 "scripts/Atlas.psm1",
7890 "scripts/Atlas.psd1",
7891 ] {
7892 assert!(is_symbol_candidate(path, None), "{path}");
7893 }
7894 assert!(!is_symbol_candidate("data/report.toon", None));
7895 }
7896
7897 #[test]
7898 fn rejected_database_location_does_not_create_or_change_database() -> Result<(), Box<dyn Error>>
7899 {
7900 let temp = tempfile::tempdir()?;
7901 let root = temp.path().join("repository");
7902 fs::create_dir_all(&root)?;
7903
7904 for uncertain in [false, true] {
7905 let database_parent = temp
7906 .path()
7907 .join(if uncertain {
7908 "uncertain"
7909 } else {
7910 "unsupported"
7911 })
7912 .join("nested");
7913 let database = database_parent.join("projectatlas.db");
7914 let rejected_path = database.clone();
7915 let result = open_atlas_store_for_project_with_location_validator(
7916 &database,
7917 &root,
7918 move |_path| {
7919 if uncertain {
7920 Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
7921 path: rejected_path,
7922 mount_point: None,
7923 filesystem_type: None,
7924 reason: "injected filesystem uncertainty".to_string(),
7925 })
7926 } else {
7927 Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
7928 path: rejected_path,
7929 mount_point: None,
7930 filesystem_type: Some("nfs".to_string()),
7931 })
7932 }
7933 },
7934 );
7935 let rejected = matches!(
7936 result,
7937 Err(CliError::Db(
7938 projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
7939 | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
7940 ))
7941 );
7942 require_eq(&rejected, &true, "typed location rejection")?;
7943 require_eq(
7944 &database_parent.exists(),
7945 &false,
7946 "rejected database parent absence",
7947 )?;
7948 require_eq(&database.exists(), &false, "rejected database absence")?;
7949
7950 let existing_database = temp.path().join(if uncertain {
7951 "uncertain-existing.db"
7952 } else {
7953 "unsupported-existing.db"
7954 });
7955 let original_bytes = b"existing database bytes stay untouched";
7956 fs::write(&existing_database, original_bytes)?;
7957 let rejected_path = existing_database.clone();
7958 let existing_result = open_atlas_store_for_project_with_location_validator(
7959 &existing_database,
7960 &root,
7961 move |_path| {
7962 if uncertain {
7963 Err(projectatlas_db::DbError::DatabaseFilesystemUncertain {
7964 path: rejected_path,
7965 mount_point: None,
7966 filesystem_type: None,
7967 reason: "injected filesystem uncertainty".to_string(),
7968 })
7969 } else {
7970 Err(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
7971 path: rejected_path,
7972 mount_point: None,
7973 filesystem_type: Some("nfs".to_string()),
7974 })
7975 }
7976 },
7977 );
7978 require_eq(
7979 &matches!(
7980 existing_result,
7981 Err(CliError::Db(
7982 projectatlas_db::DbError::DatabaseFilesystemUnsupported { .. }
7983 | projectatlas_db::DbError::DatabaseFilesystemUncertain { .. }
7984 ))
7985 ),
7986 &true,
7987 "typed existing location rejection",
7988 )?;
7989 require_eq(
7990 &fs::read(&existing_database)?,
7991 &original_bytes.to_vec(),
7992 "rejected existing database bytes",
7993 )?;
7994 }
7995 Ok(())
7996 }
7997
7998 #[test]
7999 fn operation_deadline_starts_with_default_or_shorter_explicit_timeout() {
8000 for (timeout_seconds, expected) in [
8001 (None, DEFAULT_INDEX_WORK_TIMEOUT),
8002 (Some(1), Duration::from_secs(1)),
8003 (
8004 Some(DEFAULT_INDEX_WORK_TIMEOUT.as_secs() + 1),
8005 DEFAULT_INDEX_WORK_TIMEOUT,
8006 ),
8007 ] {
8008 let options = SymbolBuildOptions::new(1_024, Some(1), timeout_seconds);
8009 let control = index_work_control(&options);
8010 assert_eq!(
8011 control
8012 .deadline()
8013 .map(|deadline| deadline.duration_since(control.started_at())),
8014 Some(expected)
8015 );
8016 }
8017 }
8018
8019 #[test]
8020 fn normal_read_refresh_delta_enforces_path_and_byte_budgets() -> Result<(), Box<dyn Error>> {
8021 let temp = tempfile::tempdir()?;
8022 let node = |path: String, size_bytes: u64| Node {
8023 path,
8024 kind: NodeKind::File,
8025 parent_path: None,
8026 extension: Some(".rs".to_string()),
8027 language: Some("rust".to_string()),
8028 size_bytes: Some(size_bytes),
8029 mtime_ns: Some(1),
8030 content_hash: Some("current-content".to_string()),
8031 };
8032 let path_bounded_nodes = (0..=NORMAL_READ_REFRESH_MAX_PATHS)
8033 .map(|index| node(format!("src/file_{index}.rs"), 1))
8034 .collect::<Vec<_>>();
8035 let path_delta = source_node_delta(temp.path(), &path_bounded_nodes, &[])
8036 .ok_or_else(|| io::Error::other("path-bounded freshness delta was missing"))?;
8037 require_eq(
8038 &path_delta.report.scope,
8039 &IndexRefreshScope::Full,
8040 "path-bounded refresh scope",
8041 )?;
8042 require_eq(
8043 &path_delta.report.changed,
8044 &(NORMAL_READ_REFRESH_MAX_PATHS + 1),
8045 "path-bounded change count",
8046 )?;
8047 require_eq(
8048 &path_delta.report.sample_paths.len(),
8049 &INDEX_FRESHNESS_SAMPLE_LIMIT,
8050 "bounded freshness sample",
8051 )?;
8052
8053 let byte_bounded_nodes = vec![node(
8054 "src/large.rs".to_string(),
8055 NORMAL_READ_REFRESH_MAX_BYTES + 1,
8056 )];
8057 let byte_delta = source_node_delta(temp.path(), &byte_bounded_nodes, &[])
8058 .ok_or_else(|| io::Error::other("byte-bounded freshness delta was missing"))?;
8059 require_eq(
8060 &byte_delta.report.scope,
8061 &IndexRefreshScope::Full,
8062 "byte-bounded refresh scope",
8063 )?;
8064 Ok(())
8065 }
8066
8067 #[test]
8068 fn controlled_freshness_plan_uses_the_callers_cancellation() -> Result<(), Box<dyn Error>> {
8069 let temp = tempfile::tempdir()?;
8070 let root = temp.path().join("repo");
8071 fs::create_dir(&root)?;
8072 fs::write(root.join("lib.rs"), "pub fn indexed() {}\n")?;
8073 let db_path = root.join(".projectatlas").join("projectatlas.db");
8074 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
8075 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8076 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
8077 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8078 drop(store);
8079
8080 let invalid_config = root.join("invalid-config.toml");
8081 fs::write(&invalid_config, "[invalid")?;
8082 let control = IndexWorkControl::new(IndexCancellation::new(), None);
8083 control.cancel();
8084 let result = open_fresh_atlas_store_for_project_controlled(
8085 &db_path,
8086 &root,
8087 Some(&invalid_config),
8088 &control,
8089 );
8090 if !matches!(
8091 result,
8092 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8093 stage: IndexWorkStage::Publication,
8094 }))
8095 ) {
8096 return Err(io::Error::other(
8097 "controlled freshness parsed policy outside the caller cancellation boundary",
8098 )
8099 .into());
8100 }
8101 Ok(())
8102 }
8103
8104 #[test]
8105 fn automatic_read_refresh_returns_typed_state_when_writer_is_unavailable()
8106 -> Result<(), Box<dyn Error>> {
8107 let temp = tempfile::tempdir()?;
8108 let root = temp.path().join("repo");
8109 fs::create_dir(&root)?;
8110 let source_path = root.join("lib.rs");
8111 let indexed_source = "pub fn indexed() {}\n";
8112 let current_source = "pub fn current() {}\n";
8113 fs::write(&source_path, indexed_source)?;
8114 let db_path = root.join(".projectatlas").join("projectatlas.db");
8115 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
8116 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8117 let mut initial_store = open_atlas_store_for_project(&db_path, &plan.root)?;
8118 run_scan_pipeline(&mut initial_store, &plan, &symbol_options)?;
8119 let initial_generation = initial_store
8120 .index_publication()?
8121 .ok_or_else(|| io::Error::other("initial publication missing"))?
8122 .generation;
8123 drop(initial_store);
8124
8125 fs::write(&source_path, current_source)?;
8126 let mut blocking_writer = open_atlas_store_for_project(&db_path, &plan.root)?;
8127 let publication =
8128 blocking_writer.begin_index_publication(&plan.publication_contract_fingerprint())?;
8129 let refresh = open_fresh_atlas_store_for_project(&db_path, &plan.root, None);
8130 let Err(CliError::RefreshRequired(report)) = refresh else {
8131 return Err(io::Error::other(
8132 "contended automatic refresh did not return typed refresh_required",
8133 )
8134 .into());
8135 };
8136 require_eq(
8137 &report.scope,
8138 &IndexRefreshScope::Incremental,
8139 "contended automatic refresh scope",
8140 )?;
8141 require_eq(
8142 &report.reason,
8143 &IndexRefreshReason::SourceChanged,
8144 "contended automatic refresh reason",
8145 )?;
8146
8147 let last_valid = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
8148 require_eq(
8149 &last_valid
8150 .index_publication()?
8151 .ok_or_else(|| io::Error::other("last-valid publication missing"))?
8152 .generation,
8153 &initial_generation,
8154 "last-valid generation during contention",
8155 )?;
8156 require_eq(
8157 &last_valid
8158 .load_file_text("lib.rs")?
8159 .ok_or_else(|| io::Error::other("last-valid source text missing"))?
8160 .content,
8161 &indexed_source.to_string(),
8162 "last-valid source during contention",
8163 )?;
8164 drop(last_valid);
8165 drop(publication);
8166
8167 let repaired = open_fresh_atlas_store_for_project(&db_path, &plan.root, None)?;
8168 require_eq(
8169 &repaired
8170 .index_publication()?
8171 .ok_or_else(|| io::Error::other("repaired publication missing"))?
8172 .generation,
8173 &initial_generation
8174 .checked_next()
8175 .ok_or_else(|| io::Error::other("test generation overflow"))?,
8176 "repaired generation after contention",
8177 )?;
8178 require_eq(
8179 &repaired
8180 .load_file_text("lib.rs")?
8181 .ok_or_else(|| io::Error::other("repaired source text missing"))?
8182 .content,
8183 ¤t_source.to_string(),
8184 "current source after contention",
8185 )?;
8186 Ok(())
8187 }
8188
8189 #[test]
8190 fn derived_source_readers_reject_bytes_outside_staged_hash() -> Result<(), Box<dyn Error>> {
8191 let temp = tempfile::tempdir()?;
8192 let path = temp.path().join("lib.rs");
8193 let staged_source = "fn first() {}\n";
8194 let changed_source = "fn other() {}\n";
8195 fs::write(&path, staged_source)?;
8196 let expected_content_hash = blake3::hash(staged_source.as_bytes()).to_hex().to_string();
8197 let node = Node {
8198 path: "lib.rs".to_string(),
8199 kind: NodeKind::File,
8200 parent_path: None,
8201 extension: Some(".rs".to_string()),
8202 language: Some("rust".to_string()),
8203 size_bytes: Some(staged_source.len() as u64),
8204 mtime_ns: Some(1),
8205 content_hash: Some(expected_content_hash.clone()),
8206 };
8207 fs::write(&path, changed_source)?;
8208
8209 let text_result = indexed_file_texts_for_nodes(
8210 temp.path(),
8211 std::slice::from_ref(&node),
8212 TextIndexOptions::new(1_024),
8213 );
8214 let Err(CliError::RefreshRequired(details)) = text_result else {
8215 return Err(io::Error::other("text derivation accepted changed source bytes").into());
8216 };
8217 require_eq(
8218 &details.reason,
8219 &IndexRefreshReason::SourceChanged,
8220 "text source-change reason",
8221 )?;
8222 require_eq(
8223 &details.sample_paths,
8224 &vec!["lib.rs".to_string()],
8225 "text source-change paths",
8226 )?;
8227
8228 let symbol_outcome = parse_symbol_job(
8229 &SymbolParseJob {
8230 path: node.path,
8231 native_path: path,
8232 expected_content_hash,
8233 language: node.language,
8234 fallback_summary: None,
8235 purpose_needs_suggestion: false,
8236 },
8237 &SymbolBuildOptions::new(1_024, Some(1), None),
8238 Instant::now(),
8239 );
8240 if !matches!(
8241 symbol_outcome,
8242 SymbolParseOutcome::SourceChanged { path } if path == "lib.rs"
8243 ) {
8244 return Err(io::Error::other("symbol derivation accepted changed source bytes").into());
8245 }
8246
8247 let bounded_path = temp.path().join("bounded.txt");
8248 fs::write(&bounded_path, "four")?;
8249 let bounded_node = Node {
8250 path: "bounded.txt".to_string(),
8251 kind: NodeKind::File,
8252 parent_path: None,
8253 extension: Some(".txt".to_string()),
8254 language: Some("text".to_string()),
8255 size_bytes: Some(4),
8256 mtime_ns: Some(1),
8257 content_hash: Some(blake3::hash(b"four").to_hex().to_string()),
8258 };
8259 let bounded_control = standalone_index_work_control();
8260 let bounded_result = indexed_file_texts_for_nodes_with_limit(
8261 temp.path(),
8262 &[bounded_node],
8263 TextIndexOptions::new(1_024),
8264 3,
8265 &bounded_control,
8266 );
8267 if !matches!(
8268 bounded_result,
8269 Err(CliError::IndexWork(
8270 IndexWorkFailure::ResourceLimitExceeded {
8271 stage: IndexWorkStage::TextIndex,
8272 resource: IndexWorkResource::TextBytes,
8273 limit: 3,
8274 observed: 4,
8275 }
8276 ))
8277 ) {
8278 return Err(io::Error::other("text staging accepted bytes beyond its limit").into());
8279 }
8280
8281 let bounded_symbol = parse_symbol_job(
8282 &SymbolParseJob {
8283 path: "bounded.txt".to_string(),
8284 native_path: bounded_path,
8285 expected_content_hash: blake3::hash(b"four").to_hex().to_string(),
8286 language: Some("text".to_string()),
8287 fallback_summary: None,
8288 purpose_needs_suggestion: false,
8289 },
8290 &SymbolBuildOptions::new(3, Some(1), None),
8291 Instant::now(),
8292 );
8293 if !matches!(
8294 bounded_symbol,
8295 SymbolParseOutcome::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8296 stage: IndexWorkStage::SymbolParsing,
8297 resource: IndexWorkResource::SourceBytes,
8298 limit: 3,
8299 observed: 4,
8300 })
8301 ) {
8302 return Err(io::Error::other("symbol read accepted bytes beyond its limit").into());
8303 }
8304 Ok(())
8305 }
8306
8307 #[test]
8308 fn parser_workers_reuse_structural_summaries_without_touching_approved_purpose()
8309 -> Result<(), Box<dyn Error>> {
8310 let temp = tempfile::tempdir()?;
8311 let path = temp.path().join("package.json");
8312 let content =
8313 r#"{"name":"demo","scripts":{"test":"vitest"},"dependencies":{"react":"1.0.0"}}"#;
8314 fs::write(&path, content)?;
8315 let node = Node {
8316 path: "package.json".to_string(),
8317 kind: NodeKind::File,
8318 parent_path: None,
8319 extension: Some(".json".to_string()),
8320 language: Some("json".to_string()),
8321 size_bytes: Some(content.len() as u64),
8322 mtime_ns: Some(1),
8323 content_hash: Some(blake3::hash(content.as_bytes()).to_hex().to_string()),
8324 };
8325 let mut store = AtlasStore::in_memory()?;
8326 store.replace_scan(std::slice::from_ref(&node))?;
8327 store.set_purpose(
8328 "package.json",
8329 "Own the JavaScript package manifest.",
8330 PurposeSource::Agent,
8331 )?;
8332 let text = refresh_text_index_for_nodes_with_rows(
8333 &mut store,
8334 temp.path(),
8335 std::slice::from_ref(&node),
8336 TextIndexOptions::new(1_024),
8337 )?;
8338 let SymbolParseOutcome::Parsed(parsed) = parse_symbol_job(
8339 &SymbolParseJob {
8340 path: node.path.clone(),
8341 native_path: path,
8342 expected_content_hash: node
8343 .content_hash
8344 .clone()
8345 .ok_or_else(|| io::Error::other("fixture hash missing"))?,
8346 language: node.language.clone(),
8347 fallback_summary: None,
8348 purpose_needs_suggestion: false,
8349 },
8350 &SymbolBuildOptions::new(1_024, Some(1), None),
8351 Instant::now(),
8352 ) else {
8353 return Err(io::Error::other("package manifest did not parse").into());
8354 };
8355 require_eq(
8356 &parsed.summary_is_structural,
8357 &true,
8358 "parser-owned structural summary",
8359 )?;
8360 require_eq(
8361 &parsed.summary.as_str(),
8362 &"package manifest for demo with scripts test and 1 dependencies.",
8363 "structural content summary",
8364 )?;
8365 require_eq(
8366 &parsed.purpose_suggestion.is_none(),
8367 &true,
8368 "approved-purpose suggestion suppression",
8369 )?;
8370 let retained_bytes = symbol_parse_output_bytes(&parsed);
8371 let symbols = SymbolBuildStage {
8372 report: empty_symbol_build_report(),
8373 changes: vec![SymbolProjectionChange::Parsed(parsed)],
8374 retained_bytes,
8375 };
8376 let protected_purpose_paths = HashSet::from(["package.json".to_string()]);
8377 let control = standalone_index_work_control();
8378 let structural = stage_structural_summaries_for_nodes_controlled(
8379 &store,
8380 std::slice::from_ref(&node),
8381 &text.rows,
8382 Some(&symbols),
8383 &protected_purpose_paths,
8384 1,
8385 &control,
8386 )?;
8387 require_eq(
8388 &structural.report.summarized,
8389 &1,
8390 "structural summary report",
8391 )?;
8392 require_eq(
8393 &structural.report.purpose_suggestions,
8394 &0,
8395 "structural purpose-suggestion report",
8396 )?;
8397 require_eq(
8398 &structural.changes.is_empty(),
8399 &true,
8400 "duplicate structural mutations",
8401 )?;
8402
8403 apply_symbol_build_stage(&mut store, &symbols, &control)?;
8404 apply_structural_summary_stage(&mut store, &structural, &control)?;
8405 let indexed = store
8406 .load_node_by_path("package.json")?
8407 .ok_or_else(|| io::Error::other("indexed package manifest missing"))?;
8408 require_eq(
8409 &indexed.summary.as_deref(),
8410 &Some("package manifest for demo with scripts test and 1 dependencies."),
8411 "persisted structural content summary",
8412 )?;
8413 require_eq(
8414 &indexed.purpose.purpose.as_deref(),
8415 &Some("Own the JavaScript package manifest."),
8416 "approved purpose text",
8417 )?;
8418 require_eq(
8419 &indexed.purpose.status,
8420 &PurposeStatus::Approved,
8421 "approved purpose status",
8422 )?;
8423 Ok(())
8424 }
8425
8426 #[test]
8427 fn symbol_build_clamps_file_bytes_and_bounds_all_published_output() -> Result<(), Box<dyn Error>>
8428 {
8429 let temp = tempfile::tempdir()?;
8430 fs::write(
8431 temp.path().join("lib.rs"),
8432 "pub fn first() { second(); }\nfn second() {}\n",
8433 )?;
8434 let nodes = scan_repo(temp.path(), &ScanOptions::default())?;
8435 let mut store = AtlasStore::in_memory()?;
8436 store.replace_scan(&nodes)?;
8437 let options = SymbolBuildOptions::new(u64::MAX, Some(INDEX_WORKER_SAFE_CEILING), None);
8438 require_eq(
8439 &options.max_bytes,
8440 &MAX_SYMBOL_FILE_BYTES,
8441 "effective CLI/MCP symbol file limit",
8442 )?;
8443 let control = standalone_index_work_control();
8444 for resource in [
8445 IndexWorkResource::SymbolRows,
8446 IndexWorkResource::RelationRows,
8447 IndexWorkResource::OutputBytes,
8448 ] {
8449 if !matches!(
8450 checked_symbol_publication_usage(1, 1, 1, resource),
8451 Err(CliError::IndexWork(
8452 IndexWorkFailure::ResourceLimitExceeded { observed: 2, .. }
8453 ))
8454 ) {
8455 return Err(io::Error::other(format!(
8456 "symbol publication did not accumulate {resource}"
8457 ))
8458 .into());
8459 }
8460 }
8461 let cases = [
8462 (
8463 SymbolPublicationLimits {
8464 symbol_rows: 0,
8465 relation_rows: u64::MAX,
8466 output_bytes: u64::MAX,
8467 },
8468 IndexWorkResource::SymbolRows,
8469 ),
8470 (
8471 SymbolPublicationLimits {
8472 symbol_rows: u64::MAX,
8473 relation_rows: 0,
8474 output_bytes: u64::MAX,
8475 },
8476 IndexWorkResource::RelationRows,
8477 ),
8478 (
8479 SymbolPublicationLimits {
8480 symbol_rows: u64::MAX,
8481 relation_rows: u64::MAX,
8482 output_bytes: 0,
8483 },
8484 IndexWorkResource::OutputBytes,
8485 ),
8486 ];
8487 for (limits, expected_resource) in cases {
8488 let result = build_symbols_for_paths_with_limits(
8489 &mut store,
8490 temp.path(),
8491 &options,
8492 None,
8493 None,
8494 &control,
8495 limits,
8496 );
8497 if !matches!(
8498 result,
8499 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8500 stage: IndexWorkStage::SymbolParsing,
8501 resource,
8502 ..
8503 })) if resource == expected_resource
8504 ) {
8505 return Err(io::Error::other(format!(
8506 "symbol publication did not enforce {expected_resource}"
8507 ))
8508 .into());
8509 }
8510 require_eq(
8511 &store.symbol_count_for_path("lib.rs")?,
8512 &0,
8513 "no over-limit symbol output persisted",
8514 )?;
8515 }
8516
8517 let mut oversized_node = nodes
8518 .iter()
8519 .find(|node| node.path == "lib.rs")
8520 .cloned()
8521 .ok_or_else(|| io::Error::other("oversized symbol fixture node missing"))?;
8522 oversized_node.size_bytes = Some(4);
8523 let clear_bytes = oversized_node.path.len() as u64
8524 + oversized_node.language.as_ref().map_or(0, String::len) as u64;
8525 let clear_options = SymbolBuildOptions::new(3, Some(1), None);
8526 let clear_limits = SymbolPublicationLimits {
8527 symbol_rows: u64::MAX,
8528 relation_rows: u64::MAX,
8529 output_bytes: clear_bytes.saturating_sub(1),
8530 };
8531 let clear_result = stage_symbols_for_nodes_with_limits(
8532 &store,
8533 temp.path(),
8534 #[cfg(feature = "optional-parser-supervisor")]
8535 &OptionalParserPackProjectSelection::Inactive,
8536 std::slice::from_ref(&oversized_node),
8537 &clear_options,
8538 None,
8539 None,
8540 &HashSet::new(),
8541 &control,
8542 clear_limits,
8543 );
8544 if !matches!(
8545 clear_result,
8546 Err(CliError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
8547 stage: IndexWorkStage::SymbolParsing,
8548 resource: IndexWorkResource::OutputBytes,
8549 limit,
8550 observed,
8551 })) if limit == clear_bytes.saturating_sub(1) && observed == clear_bytes
8552 ) {
8553 return Err(
8554 io::Error::other("symbol clear output bypassed its retained-byte limit").into(),
8555 );
8556 }
8557 let clear_stage = stage_symbols_for_nodes_with_limits(
8558 &store,
8559 temp.path(),
8560 #[cfg(feature = "optional-parser-supervisor")]
8561 &OptionalParserPackProjectSelection::Inactive,
8562 std::slice::from_ref(&oversized_node),
8563 &clear_options,
8564 None,
8565 None,
8566 &HashSet::new(),
8567 &control,
8568 SymbolPublicationLimits {
8569 output_bytes: clear_bytes,
8570 ..SymbolPublicationLimits::STANDARD
8571 },
8572 )?;
8573 require_eq(
8574 &clear_stage.retained_bytes,
8575 &clear_bytes,
8576 "retained symbol clear bytes",
8577 )?;
8578 if !matches!(
8579 clear_stage.changes.as_slice(),
8580 [SymbolProjectionChange::Clear { path, language }]
8581 if path == "lib.rs" && language.as_deref() == Some("rust")
8582 ) {
8583 return Err(
8584 io::Error::other("oversized symbol output did not retain one clear").into(),
8585 );
8586 }
8587
8588 let report = build_symbols_for_paths_with_limits(
8589 &mut store,
8590 temp.path(),
8591 &options,
8592 None,
8593 None,
8594 &control,
8595 SymbolPublicationLimits::STANDARD,
8596 )?;
8597 require_eq(&report.parsed, &1, "compatible bounded symbol build")?;
8598 require_eq(&report.max_workers, &1, "single-job symbol worker count")?;
8599 if report.symbols == 0 || report.relations == 0 {
8600 return Err(io::Error::other("bounded symbol build omitted parser output").into());
8601 }
8602 Ok(())
8603 }
8604
8605 #[test]
8606 fn purpose_import_skips_non_utf8_source_headers_but_keeps_authored_inputs_strict()
8607 -> Result<(), Box<dyn Error>> {
8608 let temp = tempfile::tempdir()?;
8609 let config_path = init_config_path(temp.path(), None);
8610 init_project_with_config(temp.path(), Some(&config_path))?;
8611 let config = load_atlas_config(Some(&config_path))?;
8612 fs::write(
8613 temp.path().join("binary.txt"),
8614 b"// Purpose: Must not be imported from binary source.\n\xff",
8615 )?;
8616 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
8617 let nodes = scan_repo(&plan.root, &plan.scan_options)?;
8618 if !nodes.iter().any(|node| node.path == "binary.txt") {
8619 return Err(io::Error::other("binary source fixture was not scanned").into());
8620 }
8621 let snapshot =
8622 plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control())?;
8623 if snapshot
8624 .records
8625 .iter()
8626 .any(|record| record.path == "binary.txt")
8627 {
8628 return Err(io::Error::other("non-UTF-8 source header imported a purpose").into());
8629 }
8630
8631 fs::write(&config.map_path, [0xff])?;
8632 let strict =
8633 plan.purpose_import_snapshot_controlled(&nodes, &standalone_index_work_control());
8634 if !matches!(
8635 strict,
8636 Err(CliError::InvalidInput(message))
8637 if message.contains("purpose input is not valid UTF-8")
8638 ) {
8639 return Err(
8640 io::Error::other("non-UTF-8 authored purpose input was not rejected").into(),
8641 );
8642 }
8643 Ok(())
8644 }
8645
8646 #[test]
8647 fn purpose_import_inputs_observe_cancellation_limits_and_rollback() -> Result<(), Box<dyn Error>>
8648 {
8649 struct CancelAfterFirstRead {
8650 bytes: io::Cursor<Vec<u8>>,
8651 cancellation: IndexCancellation,
8652 reads: usize,
8653 }
8654
8655 impl Read for CancelAfterFirstRead {
8656 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
8657 let read = self.bytes.read(buffer)?;
8658 self.reads += 1;
8659 if self.reads == 1 {
8660 self.cancellation.cancel();
8661 }
8662 Ok(read)
8663 }
8664 }
8665
8666 let temp = tempfile::tempdir()?;
8667 let config_path = init_config_path(temp.path(), None);
8668 init_project_with_config(temp.path(), Some(&config_path))?;
8669 let fixture_config = load_atlas_config(Some(&config_path))?;
8670 fs::write(temp.path().join("lib.rs"), "fn imported() {}\n")?;
8671 fs::write(
8672 &fixture_config.map_path,
8673 "folders[1]:\n .,Controlled imported repository purpose\n",
8674 )?;
8675 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
8676 let nodes = scan_repo(&plan.root, &plan.scan_options)?;
8677 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8678 let mut store = AtlasStore::in_memory()?;
8679 store.set_project_root(&plan.root)?;
8680 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8681 let publication_before = store
8682 .index_publication()?
8683 .ok_or_else(|| io::Error::other("controlled import publication missing"))?;
8684 let root_before = store
8685 .load_node_by_path(".")?
8686 .ok_or_else(|| io::Error::other("controlled import root missing"))?;
8687 let text_before = store
8688 .load_file_text("lib.rs")?
8689 .ok_or_else(|| io::Error::other("controlled import text missing"))?;
8690 let symbol_count_before = store.symbol_count()?;
8691 let relation_count_before = store.symbol_relation_count()?;
8692
8693 let config_bytes = fs::metadata(&config_path)?.len();
8694 let map_bytes = fs::metadata(&fixture_config.map_path)?.len();
8695 let nonsource_bytes = fs::metadata(&fixture_config.nonsource_files_path)?.len();
8696 let source_bytes = fs::metadata(temp.path().join("lib.rs"))?.len();
8697 let staged_input_bytes = config_bytes
8698 .saturating_add(map_bytes)
8699 .saturating_add(nonsource_bytes)
8700 .saturating_add(source_bytes);
8701 let complete_operation_bytes = config_bytes
8702 .saturating_add(staged_input_bytes)
8703 .saturating_add(config_bytes)
8704 .saturating_add(staged_input_bytes);
8705 let largest_complete_input = config_bytes.max(map_bytes).max(nonsource_bytes);
8706 let aggregate_limits = PurposeImportLimits {
8707 total_bytes: complete_operation_bytes,
8708 complete_file_bytes: largest_complete_input,
8709 header_bytes: source_bytes,
8710 records: 100,
8711 };
8712 let aggregate_control = standalone_index_work_control();
8713 let aggregate_plan = ScanRuntimePlan::for_path_controlled_with_limits(
8714 None,
8715 temp.path(),
8716 None,
8717 &aggregate_control,
8718 aggregate_limits,
8719 )?;
8720 let aggregate_nodes = scan_repo(&aggregate_plan.root, &aggregate_plan.scan_options)?;
8721 let aggregate_snapshot = aggregate_plan.purpose_import_snapshot_controlled_with_limits(
8722 &aggregate_nodes,
8723 &aggregate_control,
8724 aggregate_limits,
8725 )?;
8726 revalidate_index_publication_inputs_controlled_with_limits(
8727 &store,
8728 &aggregate_plan,
8729 Some(&aggregate_snapshot.fingerprint),
8730 &aggregate_control,
8731 aggregate_limits,
8732 )?;
8733
8734 let cumulative_limit = complete_operation_bytes.saturating_sub(1);
8735 let cumulative_limits = PurposeImportLimits {
8736 total_bytes: cumulative_limit,
8737 ..aggregate_limits
8738 };
8739 let cumulative_control = standalone_index_work_control();
8740 let cumulative_plan = ScanRuntimePlan::for_path_controlled_with_limits(
8741 None,
8742 temp.path(),
8743 None,
8744 &cumulative_control,
8745 cumulative_limits,
8746 )?;
8747 let cumulative_nodes = scan_repo(&cumulative_plan.root, &cumulative_plan.scan_options)?;
8748 let cumulative_snapshot = cumulative_plan.purpose_import_snapshot_controlled_with_limits(
8749 &cumulative_nodes,
8750 &cumulative_control,
8751 cumulative_limits,
8752 )?;
8753 let cumulative_result = revalidate_index_publication_inputs_controlled_with_limits(
8754 &store,
8755 &cumulative_plan,
8756 Some(&cumulative_snapshot.fingerprint),
8757 &cumulative_control.with_timeout_ceiling(DEFAULT_INDEX_WORK_TIMEOUT),
8758 cumulative_limits,
8759 );
8760 if !matches!(
8761 cumulative_result,
8762 Err(CliError::IndexWork(
8763 IndexWorkFailure::ResourceLimitExceeded {
8764 stage: IndexWorkStage::Publication,
8765 resource: IndexWorkResource::PurposeBytes,
8766 limit,
8767 observed,
8768 }
8769 )) if limit == cumulative_limit && observed > limit
8770 ) {
8771 return Err(io::Error::other(
8772 "plan, staging, and revalidation readers did not share one purpose-byte budget",
8773 )
8774 .into());
8775 }
8776
8777 let control = standalone_index_work_control();
8778 let staged_snapshot = plan.purpose_import_snapshot_controlled(&nodes, &control)?;
8779 let small_limits = PurposeImportLimits {
8780 total_bytes: 64,
8781 complete_file_bytes: 64,
8782 header_bytes: 64,
8783 records: 100,
8784 };
8785 let initial_limit_control = standalone_index_work_control();
8786 let initial_limited = plan.purpose_import_snapshot_controlled_with_limits(
8787 &nodes,
8788 &initial_limit_control,
8789 small_limits,
8790 );
8791 if !matches!(
8792 initial_limited,
8793 Err(CliError::IndexWork(
8794 IndexWorkFailure::ResourceLimitExceeded {
8795 stage: IndexWorkStage::Publication,
8796 resource: IndexWorkResource::PurposeBytes,
8797 limit: 64,
8798 observed: 65,
8799 }
8800 ))
8801 ) {
8802 return Err(io::Error::other(
8803 "initial purpose snapshot exceeded limits without a typed failure",
8804 )
8805 .into());
8806 }
8807 let initial_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
8808 initial_cancel.cancel();
8809 if !matches!(
8810 plan.purpose_import_snapshot_controlled(&nodes, &initial_cancel),
8811 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8812 stage: IndexWorkStage::Publication,
8813 }))
8814 ) {
8815 return Err(io::Error::other(
8816 "initial purpose snapshot ignored operation cancellation",
8817 )
8818 .into());
8819 }
8820
8821 let contract_fingerprint = plan.publication_contract_fingerprint();
8822 let mut publication = store.begin_index_publication(&contract_fingerprint)?;
8823 publication.set_project_root(&plan.root)?;
8824 publication.replace_scan(&nodes)?;
8825 publication.set_purpose(".", "Uncommitted purpose input", PurposeSource::Imported)?;
8826 let publication_limit_control = standalone_index_work_control();
8827 let limited = revalidate_index_publication_inputs_controlled_with_limits(
8828 &publication,
8829 &plan,
8830 Some(&staged_snapshot.fingerprint),
8831 &publication_limit_control,
8832 small_limits,
8833 );
8834 if !matches!(
8835 limited,
8836 Err(CliError::IndexWork(
8837 IndexWorkFailure::ResourceLimitExceeded {
8838 stage: IndexWorkStage::Publication,
8839 resource: IndexWorkResource::PurposeBytes,
8840 limit: 64,
8841 observed: 65,
8842 }
8843 ))
8844 ) {
8845 return Err(io::Error::other(
8846 "publication purpose inputs exceeded limits without a typed failure",
8847 )
8848 .into());
8849 }
8850 drop(publication);
8851 require_eq(
8852 &store.index_publication()?,
8853 &Some(publication_before.clone()),
8854 "publication after bounded purpose-input rollback",
8855 )?;
8856 require_eq(
8857 &store.load_node_by_path(".")?,
8858 &Some(root_before.clone()),
8859 "authored purpose after bounded input rollback",
8860 )?;
8861 require_eq(
8862 &store.load_file_text("lib.rs")?,
8863 &Some(text_before.clone()),
8864 "indexed text after bounded input rollback",
8865 )?;
8866 require_eq(
8867 &store.symbol_count()?,
8868 &symbol_count_before,
8869 "symbols after bounded input rollback",
8870 )?;
8871 require_eq(
8872 &store.symbol_relation_count()?,
8873 &relation_count_before,
8874 "relations after bounded input rollback",
8875 )?;
8876
8877 let mut canceled_publication = store.begin_index_publication(&contract_fingerprint)?;
8878 canceled_publication.set_project_root(&plan.root)?;
8879 canceled_publication.replace_scan(&nodes)?;
8880 canceled_publication.set_purpose(
8881 ".",
8882 "Canceled uncommitted purpose input",
8883 PurposeSource::Imported,
8884 )?;
8885 let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
8886 late_cancel.cancel();
8887 let canceled = revalidate_index_publication_inputs_controlled(
8888 &canceled_publication,
8889 &plan,
8890 Some(&staged_snapshot.fingerprint),
8891 &late_cancel,
8892 );
8893 if !matches!(
8894 canceled,
8895 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8896 stage: IndexWorkStage::Publication,
8897 }))
8898 ) {
8899 return Err(io::Error::other(
8900 "late purpose-input revalidation ignored operation cancellation",
8901 )
8902 .into());
8903 }
8904 drop(canceled_publication);
8905 require_eq(
8906 &store.index_publication()?,
8907 &Some(publication_before),
8908 "publication after canceled purpose-input rollback",
8909 )?;
8910 require_eq(
8911 &store.load_node_by_path(".")?,
8912 &Some(root_before),
8913 "authored purpose after canceled input rollback",
8914 )?;
8915 require_eq(
8916 &store.load_file_text("lib.rs")?,
8917 &Some(text_before),
8918 "indexed text after canceled input rollback",
8919 )?;
8920 require_eq(
8921 &store.symbol_count()?,
8922 &symbol_count_before,
8923 "symbols after canceled input rollback",
8924 )?;
8925 require_eq(
8926 &store.symbol_relation_count()?,
8927 &relation_count_before,
8928 "relations after canceled input rollback",
8929 )?;
8930
8931 let record_limited = plan.purpose_import_snapshot_controlled_with_limits(
8932 &nodes,
8933 &control,
8934 PurposeImportLimits {
8935 records: 0,
8936 ..PurposeImportLimits::default()
8937 },
8938 );
8939 if !matches!(
8940 record_limited,
8941 Err(CliError::IndexWork(
8942 IndexWorkFailure::ResourceLimitExceeded {
8943 stage: IndexWorkStage::Publication,
8944 resource: IndexWorkResource::PurposeRecords,
8945 limit: 0,
8946 observed,
8947 }
8948 )) if observed > 0
8949 ) {
8950 return Err(io::Error::other("purpose record limit was not enforced").into());
8951 }
8952
8953 let cancellation = IndexCancellation::new();
8954 let cancel_control = IndexWorkControl::new(cancellation.clone(), None);
8955 let mut reader =
8956 PurposeInputReader::new(&plan, &cancel_control, PurposeImportLimits::default());
8957 let mut input = CancelAfterFirstRead {
8958 bytes: io::Cursor::new(vec![b'x'; CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2]),
8959 cancellation,
8960 reads: 0,
8961 };
8962 let canceled = reader.read_bytes(
8963 Path::new("controlled-purpose-input"),
8964 &mut input,
8965 u64::try_from(CONTROLLED_SOURCE_READ_BUFFER_BYTES * 2).unwrap_or(u64::MAX),
8966 true,
8967 );
8968 if !matches!(
8969 canceled,
8970 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
8971 stage: IndexWorkStage::Publication,
8972 }))
8973 ) {
8974 return Err(io::Error::other(
8975 "purpose input did not observe cancellation between chunks",
8976 )
8977 .into());
8978 }
8979 Ok(())
8980 }
8981
8982 #[test]
8983 fn publication_revalidation_rejects_source_policy_and_import_drift()
8984 -> Result<(), Box<dyn Error>> {
8985 let temp = tempfile::tempdir()?;
8986 let config_dir = temp.path().join(".projectatlas");
8987 fs::create_dir_all(&config_dir)?;
8988 let source_path = temp.path().join("lib.rs");
8989 let staged_source = "fn first() {}\n";
8990 fs::write(&source_path, staged_source)?;
8991 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
8992 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
8993 let db_path = config_dir.join("projectatlas.db");
8994 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
8995 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
8996 let control = standalone_index_work_control();
8997 let generation_before_contention = store
8998 .index_publication()?
8999 .ok_or_else(|| io::Error::other("initial publication missing"))?
9000 .generation;
9001 let contract_fingerprint = plan.publication_contract_fingerprint();
9002 let contended_batch =
9003 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9004 revalidate_staged_publication_inputs_controlled(
9005 &plan,
9006 contended_batch.nodes.expected_nodes(),
9007 None,
9008 &control,
9009 )?;
9010 let writer_blocker = rusqlite::Connection::open(&db_path)?;
9011 writer_blocker.execute_batch("BEGIN IMMEDIATE")?;
9012 let source_after_contention = "fn later() {}\n";
9013 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
9014 let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
9015 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
9016 let publisher = std::thread::spawn(move || {
9017 if ready_tx.send(()).is_err() || publish_rx.recv().is_err() {
9018 return;
9019 }
9020 let publication_control = standalone_index_work_control();
9021 let result = publish_index_batch(&mut store, contended_batch, &publication_control);
9022 drop(result_tx.send((store, result)));
9023 });
9024 ready_rx
9025 .recv_timeout(Duration::from_secs(1))
9026 .map_err(|source| {
9027 io::Error::other(format!("publisher did not become ready: {source}"))
9028 })?;
9029 fs::write(&source_path, source_after_contention)?;
9030 publish_tx
9031 .send(())
9032 .map_err(|source| io::Error::other(format!("publisher stopped early: {source}")))?;
9033 let timely_result = result_rx.recv_timeout(Duration::from_millis(500));
9034 writer_blocker.execute_batch("ROLLBACK")?;
9035 let (returned_store, contention_result) = match timely_result {
9036 Ok(result) => result,
9037 Err(source) => {
9038 publisher
9039 .join()
9040 .map_err(|_panic| io::Error::other("publication thread panicked"))?;
9041 return Err(io::Error::other(format!(
9042 "publication waited for a contending writer instead of failing fast: {source}"
9043 ))
9044 .into());
9045 }
9046 };
9047 publisher
9048 .join()
9049 .map_err(|_panic| io::Error::other("publication thread panicked"))?;
9050 store = returned_store;
9051 let Err(CliError::Db(contention)) = contention_result else {
9052 return Err(io::Error::other(
9053 "publication waited through contention and accepted stale staged source",
9054 )
9055 .into());
9056 };
9057 if !contention.is_write_unavailable() {
9058 return Err(io::Error::other(
9059 "publication contention did not return typed write-unavailable state",
9060 )
9061 .into());
9062 }
9063 require_eq(
9064 &store
9065 .index_publication()?
9066 .ok_or_else(|| io::Error::other("publication missing after contention"))?
9067 .generation,
9068 &generation_before_contention,
9069 "publication generation after contention",
9070 )?;
9071 require_eq(
9072 &store
9073 .load_file_text("lib.rs")?
9074 .ok_or_else(|| io::Error::other("indexed text missing after contention"))?
9075 .content,
9076 &staged_source.to_string(),
9077 "indexed text after contention",
9078 )?;
9079 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
9080 require_eq(
9081 &store
9082 .load_file_text("lib.rs")?
9083 .ok_or_else(|| io::Error::other("indexed text missing after retry"))?
9084 .content,
9085 &source_after_contention.to_string(),
9086 "restaged text after contention retry",
9087 )?;
9088 let initial_generation = store
9089 .index_publication()?
9090 .ok_or_else(|| io::Error::other("retried publication missing"))?
9091 .generation;
9092 require_eq(
9093 &initial_generation,
9094 &generation_before_contention
9095 .checked_next()
9096 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
9097 "single generation advance after contention retry",
9098 )?;
9099 let mut competing_store = open_atlas_store_for_project(&db_path, &plan.root)?;
9100 let competing_publication = competing_store
9101 .begin_index_projection_refresh_from(&contract_fingerprint, initial_generation)?;
9102 competing_publication.set_node_summary("lib.rs", "winning projection")?;
9103 let generation_conflict_batch =
9104 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9105 competing_publication.complete()?;
9106 let winning_generation = initial_generation
9107 .checked_next()
9108 .ok_or_else(|| io::Error::other("test generation overflowed"))?;
9109 let conflict = publish_index_batch(&mut store, generation_conflict_batch, &control);
9110 if !matches!(
9111 conflict,
9112 Err(CliError::Db(
9113 projectatlas_db::DbError::PublicationBaseGenerationChanged {
9114 expected,
9115 found,
9116 }
9117 )) if expected == initial_generation && found == winning_generation
9118 ) {
9119 return Err(io::Error::other(
9120 "runtime publication did not reject a generation changed after preparation",
9121 )
9122 .into());
9123 }
9124 let winning_publication = store
9125 .index_publication()?
9126 .ok_or_else(|| io::Error::other("winning publication missing"))?;
9127 require_eq(
9128 &winning_publication.generation,
9129 &winning_generation,
9130 "winning publication generation",
9131 )?;
9132 require_eq(
9133 &winning_publication.state,
9134 &projectatlas_db::IndexPublicationState::Complete,
9135 "winning publication state",
9136 )?;
9137 require_eq(
9138 &store
9139 .load_node_by_path("lib.rs")?
9140 .and_then(|node| node.summary),
9141 &Some("winning projection".to_string()),
9142 "winning publication summary",
9143 )?;
9144 let staged_source_batch =
9145 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9146
9147 fs::write(&source_path, "fn other() {}\n")?;
9148 let source_result = revalidate_staged_publication_inputs_controlled(
9149 &plan,
9150 staged_source_batch.nodes.expected_nodes(),
9151 None,
9152 &control,
9153 );
9154 let Err(CliError::RefreshRequired(details)) = source_result else {
9155 return Err(io::Error::other("publication accepted changed source state").into());
9156 };
9157 require_eq(
9158 &details.reason,
9159 &IndexRefreshReason::SourceChanged,
9160 "publication source-change reason",
9161 )?;
9162 require_eq(
9163 &details.sample_paths,
9164 &vec!["lib.rs".to_string()],
9165 "publication source-change paths",
9166 )?;
9167
9168 fs::write(&source_path, staged_source)?;
9169 let staged_policy_batch =
9170 stage_full_index_publication(&store, &plan, &symbol_options, true, false, &control)?;
9171 let config_path = config_dir.join("config.toml");
9172 fs::write(
9173 &config_path,
9174 r#"[project]
9175root = "."
9176map_path = ".projectatlas/projectatlas.toon"
9177nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
9178
9179[scan]
9180source_extensions = [".rs"]
9181exclude_dir_names = [".git", ".projectatlas", "target"]
9182exclude_dir_suffixes = []
9183exclude_path_prefixes = []
9184non_source_path_prefixes = []
9185text_index_max_bytes = 7
9186
9187[purpose]
9188default_style = "line-comment"
9189line_comment_prefixes = ["//"]
9190
9191[purpose.styles_by_extension]
9192".rs" = "line-comment"
9193"#,
9194 )?;
9195 let policy_result = revalidate_staged_publication_inputs_controlled(
9196 &plan,
9197 staged_policy_batch.nodes.expected_nodes(),
9198 None,
9199 &control,
9200 );
9201 let Err(CliError::VerificationIncomplete(details)) = policy_result else {
9202 return Err(io::Error::other("publication accepted changed effective policy").into());
9203 };
9204 require_eq(
9205 &details.reason,
9206 &IndexVerificationReason::PublicationContractMismatch,
9207 "publication policy-change reason",
9208 )?;
9209
9210 let configured_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
9211 let request_limited_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
9212 require_eq(
9213 &configured_plan.text_options.max_bytes,
9214 &7,
9215 "configured text-index limit",
9216 )?;
9217 require_eq(
9218 &request_limited_plan.text_options.max_bytes,
9219 &1,
9220 "request-scoped text-index limit",
9221 )?;
9222 require_eq(
9223 &configured_plan.publication_contract_fingerprint(),
9224 &request_limited_plan.publication_contract_fingerprint(),
9225 "request limit excluded from publication contract",
9226 )?;
9227 let reloaded_request_plan = request_limited_plan.reload()?;
9228 require_eq(
9229 &reloaded_request_plan.text_options.max_bytes,
9230 &1,
9231 "request limit retained across operation reload",
9232 )?;
9233 require_eq(
9234 &request_limited_plan.publication_contract_fingerprint(),
9235 &reloaded_request_plan.publication_contract_fingerprint(),
9236 "reloaded operation publication contract",
9237 )?;
9238
9239 let changed_config = fs::read_to_string(&config_path)?
9240 .replace("text_index_max_bytes = 7", "text_index_max_bytes = 8");
9241 fs::write(&config_path, changed_config)?;
9242 let configured_cap_changed_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1))?;
9243 if configured_plan.publication_contract_fingerprint()
9244 == configured_cap_changed_plan.publication_contract_fingerprint()
9245 {
9246 return Err(io::Error::other(
9247 "configured text-index limit did not change the publication contract",
9248 )
9249 .into());
9250 }
9251
9252 let import_repo = temp.path().join("import-repo");
9253 let import_atlas_dir = import_repo.join(".projectatlas");
9254 fs::create_dir_all(&import_atlas_dir)?;
9255 fs::write(import_repo.join("lib.rs"), "fn imported() {}\n")?;
9256 let import_map_path = import_atlas_dir.join("projectatlas.toon");
9257 fs::write(
9258 &import_map_path,
9259 "folders[1]:\n .,Original imported repository purpose\n",
9260 )?;
9261 let external_config_path = temp.path().join("external-config.toml");
9262 fs::write(
9263 &external_config_path,
9264 r#"[project]
9265root = "import-repo"
9266map_path = ".projectatlas/projectatlas.toon"
9267nonsource_files_path = ".projectatlas/projectatlas-nonsource-files.toon"
9268"#,
9269 )?;
9270 let import_plan =
9271 ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, Some(1))?;
9272 let mut import_store = open_atlas_store_for_project(
9273 &import_atlas_dir.join("projectatlas.db"),
9274 &import_plan.root,
9275 )?;
9276 run_scan_pipeline(&mut import_store, &import_plan, &symbol_options)?;
9277 verify_index_freshness(&import_store, &import_repo, Some(&external_config_path))?;
9278 let normal_import_plan =
9279 ScanRuntimePlan::for_path(Some(&external_config_path), &import_repo, None)?;
9280 run_symbol_build_pipeline(
9281 &mut import_store,
9282 &normal_import_plan,
9283 &symbol_options,
9284 None,
9285 )?;
9286 verify_index_publication(&import_store, &normal_import_plan)?;
9287 let publication_before = import_store
9288 .index_publication()?
9289 .ok_or_else(|| io::Error::other("initial imported publication missing"))?;
9290 let root_before = import_store
9291 .load_node_by_path(".")?
9292 .ok_or_else(|| io::Error::other("imported root node missing"))?;
9293 if root_before.purpose.purpose.as_deref() != Some("Original imported repository purpose") {
9294 return Err(io::Error::other("legacy purpose fixture was not imported").into());
9295 }
9296
9297 let import_control = standalone_index_work_control();
9298 let staged_import_batch = stage_full_index_publication(
9299 &import_store,
9300 &import_plan,
9301 &symbol_options,
9302 true,
9303 true,
9304 &import_control,
9305 )?;
9306 let staged_purpose_import = staged_import_batch
9307 .purpose_import
9308 .as_ref()
9309 .ok_or_else(|| io::Error::other("staged purpose import missing"))?;
9310 if !staged_purpose_import
9311 .records
9312 .iter()
9313 .any(|record| record.summary == "Original imported repository purpose")
9314 {
9315 return Err(io::Error::other("legacy purpose fixture was not imported").into());
9316 }
9317 fs::write(
9318 &import_map_path,
9319 "folders[1]:\n .,Changed imported repository purpose\n",
9320 )?;
9321 let import_result = revalidate_staged_publication_inputs_with_purpose_snapshot(
9322 &import_plan,
9323 staged_import_batch.nodes.expected_nodes(),
9324 Some(staged_purpose_import),
9325 &import_control,
9326 );
9327 let Err(CliError::VerificationIncomplete(details)) = import_result else {
9328 return Err(io::Error::other(
9329 "publication accepted changed legacy purpose import inputs",
9330 )
9331 .into());
9332 };
9333 require_eq(
9334 &details.reason,
9335 &IndexVerificationReason::PublicationContractMismatch,
9336 "publication import-change reason",
9337 )?;
9338 require_eq(
9339 &import_store.index_publication()?,
9340 &Some(publication_before),
9341 "publication after purpose-import rollback",
9342 )?;
9343 require_eq(
9344 &import_store.load_node_by_path(".")?,
9345 &Some(root_before),
9346 "authored purpose after purpose-import rollback",
9347 )?;
9348 Ok(())
9349 }
9350
9351 #[test]
9352 fn semantic_contract_revision_forces_full_projection_refresh() -> Result<(), Box<dyn Error>> {
9353 const PRE_MODULE_CALLBACK_DIGEST: &str =
9354 "487625adf2f9ec76f98034d4ef5667e707960b6b8afd280b213021cb64a0f10f";
9355 let temp = tempfile::tempdir()?;
9356 let atlas_dir = temp.path().join(".projectatlas");
9357 fs::create_dir_all(&atlas_dir)?;
9358 fs::create_dir_all(temp.path().join("src"))?;
9359 fs::write(
9360 temp.path().join("src/config.rs"),
9361 "pub fn load_timeout_millis() -> u64 { 250 }\n",
9362 )?;
9363 fs::write(
9364 temp.path().join("src/handler.rs"),
9365 "use crate::config;\npub fn health_response() { let _ = config::load_timeout_millis(); }\n",
9366 )?;
9367 fs::write(
9368 temp.path().join("src/router.rs"),
9369 "use crate::handler;\npub fn dispatch(path: &str) -> Option<()> { (path == \"/health\").then(handler::health_response) }\n",
9370 )?;
9371
9372 let plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
9373 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9374 let db_path = atlas_dir.join("projectatlas.db");
9375 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
9376 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
9377 let current_fingerprint = plan.publication_contract_fingerprint();
9378 let legacy_fingerprint = index_derivation_fingerprint_with_semantic_digest(
9379 &plan.scan_options,
9380 text_index_options(plan.config.as_ref(), None),
9381 #[cfg(feature = "optional-parser-supervisor")]
9382 &plan.optional_parser_selection,
9383 PRE_MODULE_CALLBACK_DIGEST,
9384 );
9385 if legacy_fingerprint == current_fingerprint {
9386 return Err(io::Error::other(
9387 "semantic contract revision did not change the derivation fingerprint",
9388 )
9389 .into());
9390 }
9391
9392 let current_generation = store
9393 .index_publication()?
9394 .ok_or_else(|| io::Error::other("current publication missing"))?
9395 .generation;
9396 store
9397 .begin_index_publication_from(&legacy_fingerprint, current_generation)?
9398 .complete()?;
9399 if publication_contract_matches(&store, &plan)? {
9400 return Err(io::Error::other(
9401 "prior semantic contract unexpectedly matched the current plan",
9402 )
9403 .into());
9404 }
9405
9406 let stale_generation = store
9407 .index_publication()?
9408 .ok_or_else(|| io::Error::other("stale publication missing"))?
9409 .generation;
9410 let control = standalone_index_work_control();
9411 refresh_index_controlled(&mut store, &plan, &symbol_options, &control)?;
9412 let refreshed = store
9413 .index_publication()?
9414 .ok_or_else(|| io::Error::other("refreshed publication missing"))?;
9415 if refreshed.generation <= stale_generation
9416 || refreshed.contract_fingerprint.as_deref() != Some(current_fingerprint.as_str())
9417 {
9418 return Err(io::Error::other(
9419 "semantic contract mismatch did not force a current full publication",
9420 )
9421 .into());
9422 }
9423 let graphs = store.load_symbol_graphs_for_paths(&["src/router.rs".to_string()])?;
9424 if !graphs
9425 .iter()
9426 .flat_map(|graph| &graph.relations)
9427 .any(|relation| {
9428 relation.kind == projectatlas_core::symbols::RelationKind::Calls
9429 && relation.target_name == "handler::health_response"
9430 })
9431 {
9432 return Err(io::Error::other(
9433 "semantic refresh did not publish the comparison-then callback edge",
9434 )
9435 .into());
9436 }
9437 Ok(())
9438 }
9439
9440 #[cfg(feature = "optional-parser-supervisor")]
9441 #[test]
9442 fn optional_parser_selection_changes_derivation_and_preserves_prior_generation_on_failure()
9443 -> Result<(), Box<dyn Error>> {
9444 let temp = tempfile::tempdir()?;
9445 let atlas_dir = temp.path().join(".projectatlas");
9446 fs::create_dir_all(&atlas_dir)?;
9447 let source_path = temp.path().join("main.awk");
9448 fs::write(&source_path, "BEGIN { print \"atlas\" }\n")?;
9449 let inactive_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9450 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9451 let db_path = atlas_dir.join("projectatlas.db");
9452 let mut store = open_atlas_store_for_project(&db_path, &inactive_plan.root)?;
9453 run_scan_pipeline(&mut store, &inactive_plan, &symbol_options)?;
9454 if inactive_plan.scan_options.admit_optional_languages {
9455 return Err(io::Error::other(
9456 "inactive optional pack admitted catalog languages into the scan policy",
9457 )
9458 .into());
9459 }
9460 let inactive_optional = store
9461 .load_node_by_path("main.awk")?
9462 .ok_or_else(|| io::Error::other("inactive optional source node missing"))?;
9463 if inactive_optional.node.language.is_some() {
9464 return Err(io::Error::other(
9465 "inactive optional extension received a catalog language assignment",
9466 )
9467 .into());
9468 }
9469 let before = store
9470 .index_publication()?
9471 .ok_or_else(|| io::Error::other("initial publication missing"))?;
9472
9473 let selection_path = temp.path().join(repo_path_to_native(
9474 OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH,
9475 ));
9476 fs::write(
9477 &selection_path,
9478 serde_json::to_vec(&json!({
9479 "schema_version": 1,
9480 "pack_id": "broad-parser",
9481 "selected": {
9482 "projectatlas_version": OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION,
9483 "artifact": "a".repeat(64),
9484 }
9485 }))?,
9486 )?;
9487 let selected_plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9488 if !selected_plan.scan_options.admit_optional_languages {
9489 return Err(io::Error::other(
9490 "selected optional pack did not enable catalog language admission",
9491 )
9492 .into());
9493 }
9494 if inactive_plan.publication_contract_fingerprint()
9495 == selected_plan.publication_contract_fingerprint()
9496 {
9497 return Err(io::Error::other(
9498 "optional parser selection did not change the derivation contract",
9499 )
9500 .into());
9501 }
9502 if publication_contract_matches(&store, &selected_plan)? {
9503 return Err(io::Error::other(
9504 "selected optional artifact unexpectedly matched the inactive publication",
9505 )
9506 .into());
9507 }
9508 if !watch_path_requires_full_scan(temp.path(), &selection_path) {
9509 return Err(io::Error::other(
9510 "optional parser selection event did not require a full refresh",
9511 )
9512 .into());
9513 }
9514
9515 let mut changes = WatchChangeSet::default();
9516 changes.paths.insert(source_path);
9517 let result =
9518 refresh_index_for_changes(&mut store, &selected_plan, &changes, &symbol_options);
9519 if !matches!(result, Err(CliError::ParserPack(_))) {
9520 return Err(io::Error::other(
9521 "missing selected artifact did not fail before publication",
9522 )
9523 .into());
9524 }
9525 require_eq(
9526 &store
9527 .index_publication()?
9528 .ok_or_else(|| io::Error::other("publication disappeared after failure"))?
9529 .generation,
9530 &before.generation,
9531 "generation after selected optional artifact failure",
9532 )?;
9533
9534 fs::remove_file(selection_path)?;
9535 let disabled_plan = selected_plan.reload()?;
9536 if disabled_plan.scan_options.admit_optional_languages {
9537 return Err(io::Error::other(
9538 "disabled optional pack retained catalog language admission",
9539 )
9540 .into());
9541 }
9542 require_eq(
9543 &disabled_plan.publication_contract_fingerprint(),
9544 &inactive_plan.publication_contract_fingerprint(),
9545 "disabled optional parser derivation contract",
9546 )?;
9547
9548 let stale_graph = SymbolGraph {
9549 path: "main.awk".to_string(),
9550 language: Some("awk".to_string()),
9551 parser: ParserKind::Fallback,
9552 symbols: Vec::new(),
9553 relations: Vec::new(),
9554 };
9555 let stale_metadata = SourceParseMetadata {
9556 path: stale_graph.path.clone(),
9557 language: stale_graph.language.clone(),
9558 parser: ParserKind::TreeSitter,
9559 symbol_count: 0,
9560 relation_count: 0,
9561 };
9562 store.replace_symbol_graph_with_metadata(&stale_graph, &stale_metadata)?;
9563 let selected_publication = store.begin_index_publication_from(
9564 &selected_plan.publication_contract_fingerprint(),
9565 before.generation,
9566 )?;
9567 selected_publication.complete()?;
9568
9569 refresh_index(&mut store, &disabled_plan, &symbol_options)?;
9570 require_eq(
9571 &store.load_source_parse_metadata("main.awk")?,
9572 &None,
9573 "disabled optional parser metadata",
9574 )?;
9575 if !publication_contract_matches(&store, &disabled_plan)? {
9576 return Err(io::Error::other(
9577 "disabled optional parser refresh did not publish its derivation contract",
9578 )
9579 .into());
9580 }
9581 Ok(())
9582 }
9583
9584 #[test]
9585 fn symbol_projection_refresh_republishes_normalized_graph_at_one_generation()
9586 -> Result<(), Box<dyn Error>> {
9587 let temp = tempfile::tempdir()?;
9588 let atlas_dir = temp.path().join(".projectatlas");
9589 fs::create_dir_all(&atlas_dir)?;
9590 fs::write(
9591 temp.path().join("lib.rs"),
9592 "pub fn caller() { target(); }\npub fn target() {}\n",
9593 )?;
9594 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9595 let mut store =
9596 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
9597 refresh_index(
9598 &mut store,
9599 &plan,
9600 &SymbolBuildOptions::new(1_024, Some(1), None),
9601 )?;
9602 let before = store
9603 .index_publication()?
9604 .ok_or_else(|| io::Error::other("initial publication missing"))?;
9605 let relation_query = RepositoryGraphRelationQuery::Family {
9606 relation: GraphRelationKind::Legacy(RelationKind::Calls),
9607 };
9608 require_eq(
9609 &store
9610 .repository_graph_relations(relation_query.clone(), 10)?
9611 .rows
9612 .len(),
9613 &1,
9614 "initial normalized call relation",
9615 )?;
9616
9617 run_symbol_build_pipeline(
9618 &mut store,
9619 &plan,
9620 &SymbolBuildOptions::new(1, Some(1), None),
9621 None,
9622 )?;
9623
9624 let after = store
9625 .index_publication()?
9626 .ok_or_else(|| io::Error::other("symbol publication missing"))?;
9627 require_eq(
9628 &after.generation,
9629 &before
9630 .generation
9631 .checked_next()
9632 .ok_or_else(|| io::Error::other("publication generation overflowed"))?,
9633 "symbol and graph publication generation",
9634 )?;
9635 require_eq(
9636 &store
9637 .repository_graph_relations(relation_query, 10)?
9638 .rows
9639 .len(),
9640 &0,
9641 "cleared symbol relation projection",
9642 )?;
9643 let project = store
9644 .project_instance_id()?
9645 .ok_or_else(|| io::Error::other("project identity missing"))?;
9646 let path = RepositoryNodePath::new(Path::new("lib.rs"))?;
9647 let entities = store.repository_graph_entities_by_path(project, &path, 10)?;
9648 require_eq(
9649 &entities
9650 .rows
9651 .iter()
9652 .all(|entity| entity.generation() == after.generation),
9653 &true,
9654 "symbol refresh normalized graph generation",
9655 )?;
9656 Ok(())
9657 }
9658
9659 #[test]
9660 fn unchanged_full_and_incremental_refreshes_do_not_advance_generation()
9661 -> Result<(), Box<dyn Error>> {
9662 let temp = tempfile::tempdir()?;
9663 let atlas_dir = temp.path().join(".projectatlas");
9664 fs::create_dir_all(&atlas_dir)?;
9665 let source_path = temp.path().join("lib.rs");
9666 fs::write(&source_path, "fn stable() {}\n")?;
9667 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9668 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9669 let mut store =
9670 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
9671 refresh_index(&mut store, &plan, &symbol_options)?;
9672 let normal_read_plan = ScanRuntimePlan::for_path(None, temp.path(), None)?;
9673 verify_index_publication(&store, &normal_read_plan)?;
9674 let before = store
9675 .index_publication()?
9676 .ok_or_else(|| io::Error::other("initial publication missing"))?;
9677 let project = store
9678 .project_instance_id()?
9679 .ok_or_else(|| io::Error::other("project identity missing"))?;
9680 let abandoned_full_stage = atlas_dir.join("graph-stage-full-noop");
9681 fs::create_dir(&abandoned_full_stage)?;
9682 drop(AtlasStore::create_repository_graph_staging(
9683 &abandoned_full_stage.join("projectatlas.db"),
9684 &plan.root,
9685 project,
9686 )?);
9687 let full_report = refresh_index(&mut store, &plan, &symbol_options)?;
9688 let after_full = store
9689 .index_publication()?
9690 .ok_or_else(|| io::Error::other("publication missing after full no-op refresh"))?;
9691 require_eq(
9692 &after_full.generation,
9693 &before.generation,
9694 "full no-op publication generation",
9695 )?;
9696 require_eq(
9697 &full_report.text_index.candidates,
9698 &0,
9699 "full no-op text candidates",
9700 )?;
9701 require_eq(
9702 &full_report.structural_summaries.candidates,
9703 &0,
9704 "full no-op summary candidates",
9705 )?;
9706 require_eq(
9707 &full_report.symbols.candidates,
9708 &0,
9709 "full no-op symbol candidates",
9710 )?;
9711 require_eq(
9712 &full_report.symbols.max_workers,
9713 &0,
9714 "full no-op symbol workers",
9715 )?;
9716 require_eq(
9717 &abandoned_full_stage.exists(),
9718 &false,
9719 "full no-op abandoned graph stage",
9720 )?;
9721 let abandoned_incremental_stage = atlas_dir.join("graph-stage-incremental-noop");
9722 fs::create_dir(&abandoned_incremental_stage)?;
9723 drop(AtlasStore::create_repository_graph_staging(
9724 &abandoned_incremental_stage.join("projectatlas.db"),
9725 &plan.root,
9726 project,
9727 )?);
9728 let mut changes = WatchChangeSet::default();
9729 changes.paths.insert(source_path);
9730
9731 let report = refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
9732 let after = store
9733 .index_publication()?
9734 .ok_or_else(|| io::Error::other("publication missing after no-op refresh"))?;
9735
9736 require_eq(
9737 &after.generation,
9738 &before.generation,
9739 "no-op publication generation",
9740 )?;
9741 require_eq(&report.text_index.candidates, &0, "no-op text candidates")?;
9742 require_eq(
9743 &report.structural_summaries.candidates,
9744 &0,
9745 "no-op summary candidates",
9746 )?;
9747 require_eq(&report.symbols.candidates, &0, "no-op symbol candidates")?;
9748 require_eq(&report.symbols.max_workers, &0, "no-op symbol workers")?;
9749 require_eq(
9750 &abandoned_incremental_stage.exists(),
9751 &false,
9752 "incremental no-op abandoned graph stage",
9753 )?;
9754 Ok(())
9755 }
9756
9757 #[test]
9758 fn watcher_preserves_explicit_full_refresh_guidance() -> Result<(), Box<dyn Error>> {
9759 let temp = tempfile::tempdir()?;
9760 fs::write(temp.path().join("source.rs"), "pub fn source() {}\n")?;
9761 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9762 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9763 let database = temp.path().join(".projectatlas/projectatlas.db");
9764 let mut store = open_atlas_store_for_project(&database, &plan.root)?;
9765 refresh_index(&mut store, &plan, &symbol_options)?;
9766 let before = store.index_publication()?;
9767
9768 let error =
9769 run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |_| {
9770 Err(CliError::RefreshRequired(Box::new(
9771 index_policy_refresh_required(&plan.root),
9772 )))
9773 })
9774 .err()
9775 .ok_or_else(|| {
9776 io::Error::other("full-refresh guidance was hidden by polling fallback")
9777 })?;
9778 let CliError::RefreshRequired(report) = error else {
9779 return Err(io::Error::other(format!(
9780 "unexpected watcher error after full-refresh guidance: {error:?}"
9781 ))
9782 .into());
9783 };
9784 require_eq(
9785 &report.scope,
9786 &IndexRefreshScope::Full,
9787 "watcher full-refresh scope",
9788 )?;
9789 require_eq(
9790 &store.index_publication()?,
9791 &before,
9792 "watcher generation after full-refresh guidance",
9793 )?;
9794 Ok(())
9795 }
9796
9797 #[test]
9798 fn canceled_watcher_batch_preserves_last_valid_and_retries_one_generation()
9799 -> Result<(), Box<dyn Error>> {
9800 let temp = tempfile::tempdir()?;
9801 let atlas_dir = temp.path().join(".projectatlas");
9802 fs::create_dir_all(&atlas_dir)?;
9803 let reserved_purpose_path = atlas_dir.join("projectatlas-nonsource-files.toon");
9804 fs::write(&reserved_purpose_path, "nonsource_files[]:\n")?;
9805 let changed_path = temp.path().join("changed.rs");
9806 let deleted_path = temp.path().join("deleted.rs");
9807 let deleted_dir = temp.path().join("deleted");
9808 let deleted_descendant = deleted_dir.join("descendant.rs");
9809 fs::create_dir(&deleted_dir)?;
9810 fs::write(&changed_path, "pub fn before() {}\n")?;
9811 fs::write(&deleted_path, "pub fn removed() {}\n")?;
9812 fs::write(&deleted_descendant, "pub fn descendant() {}\n")?;
9813 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
9814 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
9815 let db_path = atlas_dir.join("projectatlas.db");
9816 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
9817 refresh_index(&mut store, &plan, &symbol_options)?;
9818 let before = store
9819 .index_publication()?
9820 .ok_or_else(|| io::Error::other("initial publication missing"))?;
9821 let before_node = store
9822 .load_node_by_path("changed.rs")?
9823 .ok_or_else(|| io::Error::other("initial changed node missing"))?;
9824 let reviewed_reserved_purpose = "Describe reviewed non-source atlas responsibilities.";
9825 store.set_purpose(
9826 ".projectatlas/projectatlas-nonsource-files.toon",
9827 reviewed_reserved_purpose,
9828 PurposeSource::Agent,
9829 )?;
9830 let old_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
9831 require_eq(
9832 &old_reader
9833 .index_publication()?
9834 .as_ref()
9835 .map(|state| state.generation),
9836 &Some(before.generation),
9837 "old reader generation before staged publication",
9838 )?;
9839 let old_text = old_reader
9840 .load_file_text("changed.rs")?
9841 .ok_or_else(|| io::Error::other("old reader text missing"))?;
9842
9843 fs::write(&changed_path, "pub fn after() {}\n")?;
9844 fs::remove_file(&deleted_path)?;
9845 fs::remove_dir_all(&deleted_dir)?;
9846 fs::write(&reserved_purpose_path, "nonsource_files[]:\n\n")?;
9847 let preparation_control = standalone_index_work_control();
9848 let staged_batch = stage_full_index_publication(
9849 &store,
9850 &plan,
9851 &symbol_options,
9852 true,
9853 false,
9854 &preparation_control,
9855 )?;
9856 revalidate_staged_publication_inputs_controlled(
9857 &plan,
9858 staged_batch.nodes.expected_nodes(),
9859 None,
9860 &preparation_control,
9861 )?;
9862 let IndexPublicationBatch {
9863 base_generation,
9864 contract_fingerprint,
9865 root,
9866 nodes,
9867 purpose_import: _,
9868 text_paths,
9869 text,
9870 symbols: _,
9871 graph: _,
9872 structural_summaries: _,
9873 } = staged_batch;
9874 let mut staged =
9875 store.begin_index_publication_from(&contract_fingerprint, base_generation)?;
9876 staged.set_project_root(&root)?;
9877 let NodePublicationBatch::Full { nodes } = nodes else {
9878 return Err(io::Error::other("full staging returned an incremental batch").into());
9879 };
9880 staged.begin_scan_replacement()?;
9881 for batch in nodes.chunks(PUBLICATION_NODE_BATCH_SIZE) {
9882 staged.upsert_scan_node_batch(batch)?;
9883 }
9884 staged.finish_scan_replacement()?;
9885 let late_cancel = IndexWorkControl::new(IndexCancellation::new(), None);
9886 late_cancel.cancel();
9887 let late_result = apply_text_index_stage(&mut staged, &text_paths, &text, &late_cancel);
9888 if !matches!(
9889 late_result,
9890 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
9891 stage: IndexWorkStage::Publication,
9892 }))
9893 ) {
9894 return Err(io::Error::other("late cancellation did not stop publication").into());
9895 }
9896 drop(staged);
9897 require_eq(
9898 &store
9899 .index_publication()?
9900 .as_ref()
9901 .map(|state| state.generation),
9902 &Some(before.generation),
9903 "generation after canceled publication",
9904 )?;
9905 require_eq(
9906 &store.load_node_by_path("changed.rs")?,
9907 &Some(before_node),
9908 "last-valid node after canceled publication",
9909 )?;
9910 let mut changes = WatchChangeSet::default();
9911 changes.paths.insert(changed_path);
9912 changes.paths.insert(deleted_path);
9913 changes.paths.insert(deleted_dir);
9914 changes.paths.insert(deleted_descendant);
9915 changes.paths.insert(reserved_purpose_path);
9916 let fallback_report =
9917 run_watch_with_polling_fallback(&mut store, &plan, 0, 1, &symbol_options, |store| {
9918 let canceled = IndexWorkControl::new(IndexCancellation::new(), None);
9919 canceled.cancel();
9920 let Err(error) = refresh_index_for_changes_controlled(
9921 store,
9922 &plan,
9923 &changes,
9924 &symbol_options,
9925 &canceled,
9926 ) else {
9927 return Err(CliError::InvalidInput(
9928 "canceled watcher batch unexpectedly succeeded".to_string(),
9929 ));
9930 };
9931 let generation = store.index_publication()?.map(|state| state.generation);
9932 if generation != Some(before.generation) {
9933 return Err(CliError::InvalidInput(
9934 "canceled watcher batch advanced publication before fallback".to_string(),
9935 ));
9936 }
9937 Err(error)
9938 })?;
9939 require_eq(
9940 &fallback_report.mode.as_str(),
9941 &WATCH_MODE_POLLING,
9942 "canceled notify batch fallback mode",
9943 )?;
9944 if fallback_report
9945 .fallback_reason
9946 .as_deref()
9947 .is_none_or(|reason| !reason.contains("canceled"))
9948 {
9949 return Err(io::Error::other(
9950 "polling fallback did not retain the notify failure reason",
9951 )
9952 .into());
9953 }
9954
9955 let after = store
9956 .index_publication()?
9957 .ok_or_else(|| io::Error::other("incremental publication missing"))?;
9958 require_eq(
9959 &after.generation,
9960 &before
9961 .generation
9962 .checked_next()
9963 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
9964 "one incremental publication generation",
9965 )?;
9966 require_eq(
9967 &store.load_node_by_path("deleted.rs")?.is_none(),
9968 &true,
9969 "deleted path is absent",
9970 )?;
9971 require_eq(
9972 &store
9973 .load_symbols(Some("deleted.rs"), Some("removed"), 10)?
9974 .is_empty(),
9975 &true,
9976 "deleted symbols are invalidated",
9977 )?;
9978 require_eq(
9979 &store.load_node_by_path("deleted/descendant.rs")?.is_none(),
9980 &true,
9981 "deleted descendant path is absent",
9982 )?;
9983 require_eq(
9984 &store
9985 .load_symbols(Some("deleted/descendant.rs"), Some("descendant"), 10)?
9986 .is_empty(),
9987 &true,
9988 "deleted descendant symbols are invalidated",
9989 )?;
9990 require_eq(
9991 &store
9992 .load_symbols(Some("changed.rs"), Some("after"), 10)?
9993 .len(),
9994 &1,
9995 "changed symbols are published",
9996 )?;
9997 require_eq(
9998 &store
9999 .load_symbols(Some("changed.rs"), Some("before"), 10)?
10000 .is_empty(),
10001 &true,
10002 "replaced symbols are invalidated",
10003 )?;
10004 let reserved = store
10005 .load_node_by_path(".projectatlas/projectatlas-nonsource-files.toon")?
10006 .ok_or_else(|| io::Error::other("reserved metadata node missing"))?;
10007 require_eq(
10008 &reserved.purpose.purpose.as_deref(),
10009 &Some(reviewed_reserved_purpose),
10010 "stale reviewed built-in purpose text",
10011 )?;
10012 require_eq(
10013 &reserved.purpose.status,
10014 &PurposeStatus::Approved,
10015 "reviewed built-in purpose state",
10016 )?;
10017 require_eq(
10018 &old_reader
10019 .index_publication()?
10020 .as_ref()
10021 .map(|state| state.generation),
10022 &Some(before.generation),
10023 "old reader remains on the complete prior generation",
10024 )?;
10025 require_eq(
10026 &old_reader
10027 .load_file_text("changed.rs")?
10028 .map(|text| text.content),
10029 &Some(old_text.content),
10030 "old reader remains on prior source text",
10031 )?;
10032 let new_reader = open_atlas_store_read_only_for_project(&db_path, &plan.root)?;
10033 require_eq(
10034 &new_reader
10035 .index_publication()?
10036 .as_ref()
10037 .map(|state| state.generation),
10038 &Some(after.generation),
10039 "new reader sees the complete replacement generation",
10040 )?;
10041 require_eq(
10042 &new_reader
10043 .load_file_text("changed.rs")?
10044 .map(|text| text.content),
10045 &Some("pub fn after() {}\n".to_string()),
10046 "new reader sees replacement source text",
10047 )?;
10048 new_reader.finish_index_read_snapshot()?;
10049 old_reader.finish_index_read_snapshot()?;
10050 Ok(())
10051 }
10052
10053 #[test]
10054 fn one_sided_notify_rename_events_require_full_verification() -> Result<(), Box<dyn Error>> {
10055 let temp = tempfile::tempdir()?;
10056 let old_path = temp.path().join("old.rs");
10057 let new_path = temp.path().join("new.rs");
10058 for (mode, path) in [
10059 (notify::event::RenameMode::From, old_path),
10060 (notify::event::RenameMode::To, new_path),
10061 ] {
10062 let event =
10063 Event::new(EventKind::Modify(notify::event::ModifyKind::Name(mode))).add_path(path);
10064 let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
10065 require_eq(
10066 &changes.requires_full_scan,
10067 &true,
10068 "one-sided rename full verification",
10069 )?;
10070 }
10071 Ok(())
10072 }
10073
10074 #[test]
10075 fn notify_rescan_and_ignored_policy_events_require_full_verification()
10076 -> Result<(), Box<dyn Error>> {
10077 let temp = tempfile::tempdir()?;
10078 fs::create_dir_all(temp.path().join(".projectatlas"))?;
10079 fs::write(temp.path().join(".gitignore"), ".projectatlas/\n")?;
10080 let config = temp.path().join(".projectatlas/config.toml");
10081 fs::write(&config, "[project]\nroot = \".\"\n")?;
10082 let config_event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
10083 notify::event::DataChange::Content,
10084 )))
10085 .add_path(config.clone());
10086 let config_changes =
10087 notify_event_changes(temp.path(), &ScanOptions::default(), &config_event);
10088 require_eq(
10089 &config_changes.requires_full_scan,
10090 &true,
10091 "ignored ProjectAtlas config full verification",
10092 )?;
10093 require_eq(
10094 &config_changes.paths.contains(&config),
10095 &true,
10096 "ignored ProjectAtlas config event path",
10097 )?;
10098
10099 let rescan_event = Event::new(EventKind::Any).set_flag(notify::event::Flag::Rescan);
10100 let rescan_changes =
10101 notify_event_changes(temp.path(), &ScanOptions::default(), &rescan_event);
10102 require_eq(
10103 &rescan_changes.requires_full_scan,
10104 &true,
10105 "backend rescan flag full verification",
10106 )?;
10107 Ok(())
10108 }
10109
10110 #[test]
10111 fn directory_only_deletion_re_resolves_external_inbound_callers() -> Result<(), Box<dyn Error>>
10112 {
10113 let temp = tempfile::tempdir()?;
10114 let atlas_dir = temp.path().join(".projectatlas");
10115 let source_dir = temp.path().join("src");
10116 let removed_dir = source_dir.join("removed");
10117 fs::create_dir_all(&atlas_dir)?;
10118 fs::create_dir_all(&removed_dir)?;
10119 fs::write(
10120 source_dir.join("caller.rs"),
10121 "pub fn caller() { target(); }\n",
10122 )?;
10123 fs::write(removed_dir.join("target.rs"), "pub fn target() {}\n")?;
10124
10125 let plan = ScanRuntimePlan::for_path(None, temp.path(), Some(1_024))?;
10126 let symbol_options = SymbolBuildOptions::new(1_024, Some(1), None);
10127 let mut store =
10128 open_atlas_store_for_project(&atlas_dir.join("projectatlas.db"), &plan.root)?;
10129 refresh_index(&mut store, &plan, &symbol_options)?;
10130 let before = store
10131 .index_publication()?
10132 .ok_or_else(|| io::Error::other("initial publication missing"))?;
10133 require_caller_resolution(
10134 &store,
10135 "src/caller.rs",
10136 |resolution| matches!(resolution, RelationResolution::Resolved { .. }),
10137 "initial caller resolution",
10138 )?;
10139
10140 fs::remove_dir_all(&removed_dir)?;
10141 let mut changes = WatchChangeSet::default();
10142 changes.paths.insert(removed_dir);
10143 refresh_index_for_changes(&mut store, &plan, &changes, &symbol_options)?;
10144
10145 let after = store
10146 .index_publication()?
10147 .ok_or_else(|| io::Error::other("replacement publication missing"))?;
10148 require_eq(
10149 &after.generation,
10150 &before
10151 .generation
10152 .checked_next()
10153 .ok_or_else(|| io::Error::other("test generation overflowed"))?,
10154 "directory deletion generation",
10155 )?;
10156 require_eq(
10157 &store.load_node_by_path("src/removed/target.rs")?.is_none(),
10158 &true,
10159 "deleted descendant node",
10160 )?;
10161 require_eq(
10162 &store
10163 .load_symbols(Some("src/removed/target.rs"), Some("target"), 10)?
10164 .is_empty(),
10165 &true,
10166 "deleted descendant symbol",
10167 )?;
10168 require_caller_resolution(
10169 &store,
10170 "src/caller.rs",
10171 |resolution| matches!(resolution, RelationResolution::Unresolved { .. }),
10172 "caller resolution after directory deletion",
10173 )?;
10174 Ok(())
10175 }
10176
10177 fn require_caller_resolution(
10178 store: &AtlasStore,
10179 caller_path: &str,
10180 expected: impl FnOnce(&RelationResolution) -> bool,
10181 label: &str,
10182 ) -> Result<(), Box<dyn Error>> {
10183 let project = store
10184 .project_instance_id()?
10185 .ok_or_else(|| io::Error::other("project identity missing"))?;
10186 let caller_path = RepositoryNodePath::new(Path::new(caller_path))?;
10187 let caller_entities =
10188 store.repository_graph_entities_by_path(project, &caller_path, 100)?;
10189 let relations = store.repository_graph_relations(
10190 RepositoryGraphRelationQuery::Family {
10191 relation: GraphRelationKind::Legacy(RelationKind::Calls),
10192 },
10193 100,
10194 )?;
10195 let mut matching = relations.rows.iter().filter(|relation| {
10196 caller_entities
10197 .rows
10198 .iter()
10199 .any(|entity| entity.key() == relation.source())
10200 });
10201 let relation = matching
10202 .next()
10203 .ok_or_else(|| io::Error::other(format!("{label}: caller relation missing")))?;
10204 if matching.next().is_some() {
10205 return Err(io::Error::other(format!("{label}: multiple caller relations")).into());
10206 }
10207 if !expected(relation.resolution()) {
10208 return Err(io::Error::other(format!(
10209 "{label}: unexpected resolution {:?}",
10210 relation.resolution()
10211 ))
10212 .into());
10213 }
10214 Ok(())
10215 }
10216
10217 #[test]
10218 fn purpose_curator_handoff_applies_one_stale_safe_batch() -> Result<(), Box<dyn Error>> {
10219 let temp = tempfile::tempdir()?;
10220 let root = temp.path().join("repository");
10221 fs::create_dir(&root)?;
10222 let database = temp.path().join("projectatlas.db");
10223 let mut store = AtlasStore::open_for_project(&database, &root)?;
10224 store.replace_scan(&[
10225 Node {
10226 path: "src/main.rs".to_string(),
10227 kind: NodeKind::File,
10228 parent_path: Some("src".to_string()),
10229 extension: Some(".rs".to_string()),
10230 language: Some("rust".to_string()),
10231 size_bytes: Some(12),
10232 mtime_ns: Some(10),
10233 content_hash: Some("hash-main".to_string()),
10234 },
10235 Node {
10236 path: "src/detail.rs".to_string(),
10237 kind: NodeKind::File,
10238 parent_path: Some("src".to_string()),
10239 extension: Some(".rs".to_string()),
10240 language: Some("rust".to_string()),
10241 size_bytes: Some(12),
10242 mtime_ns: Some(10),
10243 content_hash: Some("hash-detail".to_string()),
10244 },
10245 ])?;
10246 store.set_suggested_purpose("src/detail.rs", "Generated detail suggestion")?;
10247 let task = "runtime-purpose-curator";
10248 let page = purpose_curation_page(
10249 &store,
10250 &HealthQuery {
10251 start_index: 0,
10252 limit: 20,
10253 category: None,
10254 severity: Some(Severity::Warning),
10255 path_prefix: None,
10256 summary_only: false,
10257 scope: HealthScope::all(),
10258 },
10259 task,
10260 )?;
10261 require_eq(&page.actionable, &true, "actionable queue")?;
10262 require_eq(&page.items.len(), &2, "queue item count")?;
10263 require_eq(&page.task, &task.to_string(), "queue task")?;
10264 let requests = page
10265 .items
10266 .iter()
10267 .map(|item| PurposeReviewRequest {
10268 path: item.path.clone(),
10269 purpose: Some(format!("Reviewed purpose for {}", item.path)),
10270 confirm_existing: false,
10271 task: Some(page.task.clone()),
10272 work_key: Some(item.work_key.clone()),
10273 state_token: Some(item.state_token.clone()),
10274 })
10275 .collect::<Vec<_>>();
10276 let handoff = purpose_curator_handoff(page);
10277 require_eq(
10278 &handoff.execution_owner,
10279 &"agent_host",
10280 "host-owned execution",
10281 )?;
10282 require_eq(
10283 &handoff.server_started_curator,
10284 &false,
10285 "no server-started curator",
10286 )?;
10287 require_eq(
10288 &handoff.recommended_subagent_reasoning,
10289 &"lowest_host_enforced",
10290 "lowest host-enforced reasoning",
10291 )?;
10292 require_eq(&handoff.main_agent_fallback, &true, "main-agent fallback")?;
10293
10294 let mixed = vec![
10295 requests[0].clone(),
10296 PurposeReviewRequest {
10297 path: requests[1].path.clone(),
10298 purpose: Some("Explicit correction must stay separate".to_string()),
10299 confirm_existing: false,
10300 task: None,
10301 work_key: None,
10302 state_token: None,
10303 },
10304 ];
10305 match review_purposes(&store, &mixed, true) {
10306 Err(CliError::InvalidInput(_)) => {}
10307 Err(error) => {
10308 return Err(io::Error::other(format!(
10309 "mixed purpose batch returned the wrong error: {error}"
10310 ))
10311 .into());
10312 }
10313 Ok(_) => return Err(io::Error::other("mixed purpose batch was accepted").into()),
10314 }
10315 let mut partial = requests[1].clone();
10316 partial.state_token = None;
10317 match review_purposes(&store, &[requests[0].clone(), partial], true) {
10318 Err(CliError::InvalidInput(_)) => {}
10319 Err(error) => {
10320 return Err(io::Error::other(format!(
10321 "partial conditional batch returned the wrong error: {error}"
10322 ))
10323 .into());
10324 }
10325 Ok(_) => {
10326 return Err(io::Error::other("partial conditional batch was accepted").into());
10327 }
10328 }
10329 drop(store);
10330 store = AtlasStore::open_for_project(&database, &root)?;
10331 let unchanged =
10332 store.load_nodes_by_paths(&["src/main.rs".to_string(), "src/detail.rs".to_string()])?;
10333 require_eq(
10334 &unchanged.iter().all(|node| !node.purpose.agent_reviewed()),
10335 &true,
10336 "rejected batch left every purpose unapproved after reopen",
10337 )?;
10338
10339 let applied = review_purposes(&store, &requests, true)?;
10340 require_eq(&applied.changed, &2, "conditional batch changed count")?;
10341 require_eq(&applied.conflicts, &0, "conditional batch conflicts")?;
10342 require_eq(
10343 &applied
10344 .items
10345 .iter()
10346 .all(|item| item.action == PurposeReviewAction::Review),
10347 &true,
10348 "conditional batch actions",
10349 )?;
10350
10351 let repeated = review_purposes(&store, &requests, true)?;
10352 require_eq(&repeated.changed, &0, "accepted repeat changed count")?;
10353 require_eq(&repeated.conflicts, &2, "accepted repeat conflicts")?;
10354 require_eq(
10355 &repeated
10356 .items
10357 .iter()
10358 .all(|item| item.action == PurposeReviewAction::Accepted),
10359 &true,
10360 "accepted repeat actions",
10361 )?;
10362 let empty = purpose_curation_page(
10363 &store,
10364 &HealthQuery {
10365 start_index: 0,
10366 limit: 20,
10367 category: None,
10368 severity: Some(Severity::Warning),
10369 path_prefix: None,
10370 summary_only: false,
10371 scope: HealthScope::all(),
10372 },
10373 task,
10374 )?;
10375 require_eq(&empty.actionable, &false, "accepted queue is quiet")?;
10376
10377 let correction = review_purposes(
10378 &store,
10379 &[PurposeReviewRequest {
10380 path: "src/main.rs".to_string(),
10381 purpose: Some("Explicit corrected purpose".to_string()),
10382 confirm_existing: false,
10383 task: None,
10384 work_key: None,
10385 state_token: None,
10386 }],
10387 true,
10388 )?;
10389 require_eq(
10390 &correction.items[0].action,
10391 &PurposeReviewAction::Review,
10392 "explicit correction action",
10393 )?;
10394 let corrected = store
10395 .load_node_by_path("src/main.rs")?
10396 .ok_or_else(|| io::Error::other("corrected runtime path disappeared"))?;
10397 require_eq(
10398 &corrected.purpose.purpose.as_deref(),
10399 &Some("Explicit corrected purpose"),
10400 "explicit correction value",
10401 )?;
10402 Ok(())
10403 }
10404
10405 #[test]
10406 fn purpose_review_admission_bounds_input_and_prevents_partial_apply()
10407 -> Result<(), Box<dyn Error>> {
10408 let temp = tempfile::tempdir()?;
10409 let root = temp.path().join("repository");
10410 fs::create_dir(&root)?;
10411 let database = temp.path().join("projectatlas.db");
10412 let mut store = AtlasStore::open_for_project(&database, &root)?;
10413 store.replace_scan(&[
10414 Node {
10415 path: "src/first.rs".to_string(),
10416 kind: NodeKind::File,
10417 parent_path: Some("src".to_string()),
10418 extension: Some(".rs".to_string()),
10419 language: Some("rust".to_string()),
10420 size_bytes: Some(12),
10421 mtime_ns: Some(10),
10422 content_hash: Some("hash-first".to_string()),
10423 },
10424 Node {
10425 path: "src/second.rs".to_string(),
10426 kind: NodeKind::File,
10427 parent_path: Some("src".to_string()),
10428 extension: Some(".rs".to_string()),
10429 language: Some("rust".to_string()),
10430 size_bytes: Some(12),
10431 mtime_ns: Some(10),
10432 content_hash: Some("hash-second".to_string()),
10433 },
10434 ])?;
10435 store.set_suggested_purpose("src/first.rs", "Generated first purpose")?;
10436 store.set_purpose(
10437 "src/second.rs",
10438 &"x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1),
10439 PurposeSource::Imported,
10440 )?;
10441
10442 let valid_first = PurposeReviewRequest {
10443 path: "src/first.rs".to_string(),
10444 purpose: Some("Reviewed café λ purpose".to_string()),
10445 confirm_existing: false,
10446 task: None,
10447 work_key: None,
10448 state_token: None,
10449 };
10450 let oversized_report = PurposeReviewRequest {
10451 path: "src/second.rs".to_string(),
10452 purpose: None,
10453 confirm_existing: true,
10454 task: None,
10455 work_key: None,
10456 state_token: None,
10457 };
10458 let Err(error) = review_purposes(&store, &[valid_first.clone(), oversized_report], true)
10459 else {
10460 return Err(io::Error::other(
10461 "oversized retained report field was accepted before apply",
10462 )
10463 .into());
10464 };
10465 require_eq(
10466 &error
10467 .to_string()
10468 .contains("purpose review report field purpose"),
10469 &true,
10470 "oversized report field error",
10471 )?;
10472 let unchanged = store
10473 .load_node_by_path("src/first.rs")?
10474 .ok_or_else(|| io::Error::other("first review fixture disappeared"))?;
10475 require_eq(
10476 &unchanged.purpose.agent_reviewed(),
10477 &false,
10478 "report admission failure prevented partial apply",
10479 )?;
10480
10481 let oversized_field = PurposeReviewRequest {
10482 purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES + 1)),
10483 ..valid_first.clone()
10484 };
10485 let Err(error) = review_purposes(&store, &[oversized_field], true) else {
10486 return Err(io::Error::other("oversized request field passed admission").into());
10487 };
10488 require_eq(
10489 &error.to_string().contains("field purpose"),
10490 &true,
10491 "oversized input field error",
10492 )?;
10493
10494 let too_many = vec![valid_first.clone(); MAX_PURPOSE_CURATION_BATCH_ROWS + 1];
10495 let Err(error) = review_purposes(&store, &too_many, true) else {
10496 return Err(io::Error::other("oversized request count passed admission").into());
10497 };
10498 require_eq(
10499 &error.to_string().contains("maximum is 200"),
10500 &true,
10501 "oversized item count error",
10502 )?;
10503
10504 let aggregate = (0..9)
10505 .map(|index| PurposeReviewRequest {
10506 path: format!("src/{index}.rs"),
10507 purpose: Some("x".repeat(MAX_PURPOSE_REVIEW_FIELD_BYTES)),
10508 confirm_existing: false,
10509 task: None,
10510 work_key: None,
10511 state_token: None,
10512 })
10513 .collect::<Vec<_>>();
10514 let Err(error) = review_purposes(&store, &aggregate, false) else {
10515 return Err(io::Error::other("oversized aggregate request passed admission").into());
10516 };
10517 require_eq(
10518 &error.to_string().contains("aggregate string bytes"),
10519 &true,
10520 "oversized aggregate input error",
10521 )?;
10522
10523 let preview = review_purposes(&store, &[valid_first], false)?;
10524 require_eq(
10525 &preview.items[0].purpose,
10526 &"Reviewed café λ purpose".to_string(),
10527 "UTF-8 purpose compatibility",
10528 )?;
10529 require_eq(
10530 &render_purpose_review_report(&preview).contains("Reviewed café λ purpose"),
10531 &true,
10532 "UTF-8 TOON compatibility",
10533 )?;
10534 Ok(())
10535 }
10536
10537 #[test]
10538 fn indexed_navigation_read_rejects_stale_or_oversized_source_before_allocation()
10539 -> Result<(), Box<dyn Error>> {
10540 let temp = tempfile::tempdir()?;
10541 let root = temp.path().join("repository");
10542 let source_dir = root.join("src");
10543 fs::create_dir_all(&source_dir)?;
10544 let source = source_dir.join("large.rs");
10545 fs::File::create(&source)?.set_len(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1)?;
10546 let database = temp.path().join("projectatlas.db");
10547 let mut store = AtlasStore::open_for_project(&database, &root)?;
10548 store.replace_scan(&[Node {
10549 path: "src/large.rs".to_string(),
10550 kind: NodeKind::File,
10551 parent_path: Some("src".to_string()),
10552 extension: Some(".rs".to_string()),
10553 language: Some("rust".to_string()),
10554 size_bytes: Some(MAX_INDEXED_NAVIGATION_SOURCE_BYTES + 1),
10555 mtime_ns: Some(10),
10556 content_hash: Some("unused-oversized-hash".to_string()),
10557 }])?;
10558
10559 let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
10560 return Err(
10561 io::Error::other("oversized indexed source was allocated and accepted").into(),
10562 );
10563 };
10564 let CliError::VerificationIncomplete(details) = error else {
10565 return Err(io::Error::other("oversized source returned the wrong error type").into());
10566 };
10567 require_eq(
10568 &details.reason,
10569 &IndexVerificationReason::SourceTooLarge,
10570 "oversized source reason",
10571 )?;
10572
10573 fs::File::create(&source)?.set_len(1)?;
10574 let Err(error) = read_indexed_file_content(&store, "src/large.rs") else {
10575 return Err(io::Error::other("changed source size did not require refresh").into());
10576 };
10577 let CliError::RefreshRequired(details) = error else {
10578 return Err(
10579 io::Error::other("changed source size returned the wrong error type").into(),
10580 );
10581 };
10582 require_eq(
10583 &details.reason,
10584 &IndexRefreshReason::SourceChanged,
10585 "changed source size reason",
10586 )?;
10587 Ok(())
10588 }
10589
10590 #[cfg(windows)]
10591 #[test]
10592 fn extended_windows_watch_roots_keep_deleted_paths_in_scope() -> Result<(), Box<dyn Error>> {
10593 let root = Path::new(r"\\?\C:\repo");
10594 let deleted = Path::new(r"C:\repo\src\deleted.rs");
10595 require_eq(
10596 &normalized_deleted_path(root, deleted)?,
10597 &Some("src/deleted.rs".to_string()),
10598 "extended root deleted path",
10599 )?;
10600 Ok(())
10601 }
10602
10603 #[cfg(windows)]
10604 #[test]
10605 fn notify_events_normalize_unicode_paths_against_extended_windows_roots()
10606 -> Result<(), Box<dyn Error>> {
10607 let temp = tempfile::tempdir()?;
10608 let source_dir = temp.path().join("src");
10609 fs::create_dir(&source_dir)?;
10610 let deleted = source_dir.join("Über.rs");
10611 fs::write(&deleted, "pub fn before() {}\n")?;
10612 fs::remove_file(&deleted)?;
10613
10614 let root_text = temp
10615 .path()
10616 .to_str()
10617 .ok_or_else(|| io::Error::other("temporary path is not UTF-8"))?;
10618 let extended_root = if root_text.starts_with(r"\\?\") {
10619 temp.path().to_path_buf()
10620 } else {
10621 PathBuf::from(format!(r"\\?\{root_text}"))
10622 };
10623 let event = Event::new(EventKind::Remove(notify::event::RemoveKind::File))
10624 .add_path(deleted.clone());
10625 let changes = notify_event_changes(&extended_root, &ScanOptions::default(), &event);
10626
10627 require_eq(
10628 &changes.paths.contains(&deleted),
10629 &true,
10630 "native Unicode watcher path",
10631 )?;
10632 require_eq(
10633 &changes.requires_full_scan,
10634 &false,
10635 "source removal full-scan policy",
10636 )?;
10637 require_eq(
10638 &normalized_deleted_path(&extended_root, &deleted)?,
10639 &Some("src/Über.rs".to_string()),
10640 "normalized Unicode deleted path",
10641 )?;
10642 Ok(())
10643 }
10644
10645 #[cfg(unix)]
10646 #[test]
10647 fn notify_events_preserve_native_backslash_paths_on_unix() -> Result<(), Box<dyn Error>> {
10648 let temp = tempfile::tempdir()?;
10649 let source = temp.path().join(r"src\generated.rs");
10650 fs::write(&source, "pub fn generated() {}\n")?;
10651 let event = Event::new(EventKind::Modify(notify::event::ModifyKind::Data(
10652 notify::event::DataChange::Content,
10653 )))
10654 .add_path(source.clone());
10655
10656 let changes = notify_event_changes(temp.path(), &ScanOptions::default(), &event);
10657
10658 require_eq(
10659 &changes.paths,
10660 &HashSet::from([source]),
10661 "native Unix watcher paths",
10662 )?;
10663 Ok(())
10664 }
10665
10666 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
10668 where
10669 T: Debug + PartialEq,
10670 {
10671 if actual == expected {
10672 Ok(())
10673 } else {
10674 Err(io::Error::other(format!(
10675 "{label} mismatch: expected {expected:?}, got {actual:?}"
10676 ))
10677 .into())
10678 }
10679 }
10680}