Skip to main content

forge_pilot/
observe.rs

1//! Observation phase of the OODA loop.
2//!
3//! Inspects workspace paths, semantic-memory store contents, and kernel
4//! compilation state to produce a snapshot that the orient and decide
5//! phases consume.
6
7use crate::bootstrap::{
8    current_state_from_latest_manifest, is_auxiliary_bootstrap_claim,
9    is_supported_source_file_path, should_skip_workspace_dir,
10};
11use crate::config::LoopConfig;
12use crate::error::PilotError;
13use crate::{BootstrapCurrentState, BootstrapRichness};
14use chrono::{DateTime, Utc};
15use constraint_compiler::{compile_batch, CompileOutput, CompilerPolicy, ConstraintDegradation};
16use forge_engine::ForgeStore;
17use forge_memory_bridge::ProjectionImportBatchV3;
18use kernel_execution::{schedule_execution, ExecutionBudget, ScheduledExecution};
19use kernel_oracles::{
20    evaluate_causal_refuter, evaluate_conservative, evaluate_exact_bounded,
21    evaluate_minimal_perturbation, OracleAssessment, TemporalReplaySnapshot,
22};
23use knowledge_runtime::{
24    InferenceAdvisory, InferenceExplanation, KnowledgeRuntime, RiskGateDecision, Scope,
25};
26use schemars::JsonSchema;
27use semantic_memory::{
28    MemoryStore, ProjectionClaimVersion, ProjectionEntityAlias, ProjectionEpisode,
29    ProjectionEvidenceRef, ProjectionImportLogEntry, ProjectionQuery, ProjectionRelationVersion,
30};
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeSet;
33use std::fs;
34use std::path::{Path, PathBuf};
35
36/// A degradation detected during scope observation.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38pub struct ObservationDegradation {
39    pub kind: String,
40    pub detail: String,
41}
42
43/// Whether a filesystem path required for observation is present and valid.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "snake_case")]
46pub enum PathAvailability {
47    Present,
48    Missing,
49    Invalid,
50}
51
52/// Disposition of the most recent canonical import record for the queried namespace.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum ImportRecordDisposition {
56    Found,
57    NeverImported,
58    NamespaceMismatch,
59    StorageUnavailable,
60    StorageCorrupt,
61}
62
63/// Overall readiness disposition of the observation for a namespace.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65#[serde(rename_all = "snake_case")]
66pub enum ObservationDisposition {
67    Ready,
68    EmptyWorkspace,
69    ImportRequired,
70    NamespaceMismatch,
71    StorageUnavailable,
72    StorageCorrupt,
73}
74
75/// Resolved filesystem paths and their availability for a configured namespace.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77pub struct ObservationPaths {
78    pub namespace: String,
79    pub workspace_path: String,
80    pub memory_dir: String,
81    pub forge_db_path: String,
82    pub memory_dir_state: PathAvailability,
83    pub forge_db_state: PathAvailability,
84}
85
86/// Count of supported source files and imported state for the workspace.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88pub struct SourceInventory {
89    pub supported_file_count: usize,
90    pub imported_current_state: BootstrapCurrentState,
91}
92
93/// Count of Forge evidence bundles available for export.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct ForgeEvidenceInventory {
96    pub available_bundle_count: usize,
97}
98
99/// Machine-readable status summary for a namespace observation.
100///
101/// Includes import disposition, file counts, and a human-readable
102/// `exact_next_step` describing what the operator should do next.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104pub struct ObservationStatus {
105    pub disposition: ObservationDisposition,
106    pub namespace_queried: String,
107    pub resolved_workspace_path: String,
108    pub supported_file_count: usize,
109    pub imported_file_count: usize,
110    pub imported_chunk_count: usize,
111    pub imported_symbol_count: usize,
112    pub bootstrap_richness: BootstrapRichness,
113    pub available_forge_bundle_count: usize,
114    pub import_record_disposition: ImportRecordDisposition,
115    pub import_records_found: bool,
116    pub exact_next_step: String,
117    #[serde(default)]
118    pub other_import_namespaces: Vec<String>,
119}
120
121/// Aggregate health metrics for a projected scope within semantic-memory.
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123pub struct ScopeHealthSummary {
124    pub total_claim_versions: usize,
125    pub total_relation_versions: usize,
126    pub total_episodes: usize,
127    pub fragile_node_count: usize,
128    pub syndrome_count: usize,
129    pub degradation_count: usize,
130    pub unverified_claim_count: usize,
131    pub supersession_candidate_count: usize,
132    pub last_import_at: Option<String>,
133}
134
135/// Full observation snapshot produced by the observe phase.
136///
137/// Contains every input the orient, decide, and act phases need:
138/// paths, source inventory, kernel compilation output, oracle assessment,
139/// claim/relation/episode projections, and scope health.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Observation {
142    pub scope_key: stack_ids::ScopeKey,
143    pub paths: ObservationPaths,
144    pub source_inventory: SourceInventory,
145    pub status: ObservationStatus,
146    pub advisory: Option<InferenceAdvisory>,
147    pub explanation: Option<InferenceExplanation>,
148    pub risk_gate: Option<RiskGateDecision>,
149    pub import_log: Option<ProjectionImportLogEntry>,
150    pub recent_imports: Vec<ProjectionImportLogEntry>,
151    pub batch: Option<ProjectionImportBatchV3>,
152    pub compiled: Option<CompileOutput>,
153    pub scheduled: Option<ScheduledExecution>,
154    pub oracle: Option<OracleAssessment>,
155    pub causal_refutation: Option<kernel_oracles::OracleRefutationResult>,
156    pub minimal_perturbation: Option<kernel_oracles::OracleRefutationResult>,
157    pub temporal_snapshots: Vec<TemporalReplaySnapshot>,
158    pub claim_versions: Vec<ProjectionClaimVersion>,
159    pub relation_versions: Vec<ProjectionRelationVersion>,
160    pub episodes: Vec<ProjectionEpisode>,
161    pub aliases: Vec<ProjectionEntityAlias>,
162    pub evidence_refs: Vec<ProjectionEvidenceRef>,
163    pub scope_health: ScopeHealthSummary,
164    pub degradations: Vec<ObservationDegradation>,
165    #[cfg(feature = "governance")]
166    pub governance: Option<crate::governance_gate::GovernanceObservation>,
167}
168
169impl Observation {
170    /// Returns true when the observation is missing kernel payload needed for execution.
171    pub fn missing_kernel_payload(&self) -> bool {
172        self.degradations
173            .iter()
174            .any(|degradation| degradation.kind == "missing_kernel_payload")
175    }
176
177    /// Returns true when the observation is constrained to a thin-export lane.
178    pub fn thin_export_active(&self) -> bool {
179        self.degradations
180            .iter()
181            .any(|degradation| degradation.kind == "thin_export")
182    }
183}
184
185/// Builds a complete observation for the configured scope.
186///
187/// Queries the semantic-memory store, compiles constraints, runs kernel
188/// oracle evaluation, and assembles the full `Observation` snapshot.
189pub async fn observe_scope(
190    runtime: &KnowledgeRuntime,
191    store: &MemoryStore,
192    config: &LoopConfig,
193) -> Result<Observation, PilotError> {
194    let scope = &config.scope;
195    let scope_key = scope.key();
196    let paths = inspect_observation_paths(config);
197    let mut source_inventory = discover_source_inventory(config, &paths);
198    let forge_evidence_inventory = discover_forge_evidence_inventory(config, &paths);
199    let advisory = runtime.latest_inference_advisory(Some(scope)).await?;
200    let explanation = runtime.latest_inference_explanation(Some(scope)).await?;
201    let risk_gate = runtime.latest_risk_gate(Some(scope)).await?;
202
203    let recent_imports = store
204        .query_projection_imports(Some(&scope.namespace), config.observation_import_limit)
205        .await?;
206    let all_recent_imports = store
207        .query_projection_imports(None, config.observation_import_limit)
208        .await?;
209    let import_log = recent_imports
210        .iter()
211        .find(|row| row.status == "complete")
212        .cloned();
213    source_inventory.imported_current_state = current_state_from_latest_manifest(&recent_imports);
214    let status = build_observation_status(
215        scope,
216        &paths,
217        &source_inventory,
218        &forge_evidence_inventory,
219        &recent_imports,
220        &all_recent_imports,
221        import_log.is_some(),
222    );
223
224    let mut degradations = Vec::new();
225    let batch = match import_log
226        .as_ref()
227        .and_then(|row| row.kernel_payload_json.clone())
228    {
229        Some(kernel_payload_json) => {
230            match serde_json::from_value::<ProjectionImportBatchV3>(kernel_payload_json) {
231                Ok(batch) => Some(batch),
232                Err(error) => {
233                    degradations.push(ObservationDegradation {
234                        kind: "invalid_kernel_payload".into(),
235                        detail: error.to_string(),
236                    });
237                    None
238                }
239            }
240        }
241        None => {
242            if import_log.is_some() {
243                degradations.push(ObservationDegradation {
244                    kind: "missing_kernel_payload".into(),
245                    detail: scope.namespace.clone(),
246                });
247            }
248            None
249        }
250    };
251
252    let (compiled, scheduled, oracle, causal_refutation, minimal_perturbation) =
253        if let Some(batch) = &batch {
254            let compiled = compile_batch(
255                batch,
256                &CompilerPolicy {
257                    policy_version: "forge-pilot.v1".into(),
258                    include_hyperedges: config.include_hyperedges,
259                },
260            );
261            for marker in &compiled.degradations {
262                degradations.push(ObservationDegradation {
263                    kind: degradation_kind_name(marker).to_string(),
264                    detail: format!("{marker:?}"),
265                });
266            }
267            let scheduled = schedule_execution(
268                &compiled,
269                &ExecutionBudget {
270                    max_iterations: config.oracle_max_iterations.max(1),
271                    ..ExecutionBudget::default()
272                },
273            );
274            let oracle = evaluate_exact_bounded(&compiled)
275                .unwrap_or_else(|| evaluate_conservative(&compiled));
276            let primary_node_id = scheduled
277                .execution
278                .witnesses
279                .first()
280                .map(|witness| witness.node_id.clone())
281                .or_else(|| {
282                    compiled
283                        .nodes
284                        .iter()
285                        .find(|node| node.kind != "nuisance_state")
286                        .map(|node| node.node_id.clone())
287                })
288                .unwrap_or_else(|| "unsupported".into());
289            let causal_refutation = evaluate_causal_refuter(
290                &compiled,
291                &primary_node_id,
292                config.minimal_perturbation_budget,
293            );
294            let minimal_perturbation = evaluate_minimal_perturbation(
295                &compiled,
296                &primary_node_id,
297                config.minimal_perturbation_budget,
298            );
299            (
300                Some(compiled),
301                Some(scheduled),
302                Some(oracle),
303                Some(causal_refutation),
304                Some(minimal_perturbation),
305            )
306        } else {
307            (None, None, None, None, None)
308        };
309
310    let temporal_snapshots = build_temporal_snapshots(&recent_imports);
311
312    let mut projection_query = ProjectionQuery::new(scope_key.clone());
313    projection_query.limit = config.observation_projection_limit;
314    let mut claim_versions = store.query_claim_versions(projection_query.clone()).await?;
315    let relation_versions = store
316        .query_relation_versions(projection_query.clone())
317        .await?;
318    let episodes = store.query_episodes(projection_query.clone()).await?;
319    let aliases = store.query_entity_aliases(projection_query.clone()).await?;
320    let evidence_refs = store.query_evidence_refs(projection_query).await?;
321    claim_versions.retain(|claim| !is_auxiliary_bootstrap_claim(claim));
322
323    let scope_health = build_scope_health_summary(
324        &claim_versions,
325        &relation_versions,
326        &episodes,
327        scheduled.as_ref(),
328        compiled.as_ref(),
329        import_log.as_ref(),
330        &config
331            .runtime_config
332            .projection
333            .import_staleness_threshold_secs,
334    );
335
336    if matches!(status.disposition, ObservationDisposition::Ready)
337        && is_scope_stale(
338            scope,
339            import_log.as_ref(),
340            config
341                .runtime_config
342                .projection
343                .import_staleness_threshold_secs,
344        )
345    {
346        degradations.push(ObservationDegradation {
347            kind: "scope_stale".into(),
348            detail: import_log
349                .as_ref()
350                .map(|row| row.imported_at.clone())
351                .unwrap_or_else(|| "never_imported".into()),
352        });
353    }
354
355    Ok(Observation {
356        scope_key,
357        paths,
358        source_inventory,
359        status,
360        advisory,
361        explanation,
362        risk_gate,
363        import_log,
364        recent_imports,
365        batch,
366        compiled,
367        scheduled,
368        oracle,
369        causal_refutation,
370        minimal_perturbation,
371        temporal_snapshots,
372        claim_versions,
373        relation_versions,
374        episodes,
375        aliases,
376        evidence_refs,
377        scope_health,
378        degradations,
379        #[cfg(feature = "governance")]
380        governance: Some(crate::governance_gate::observe_governance(store).await),
381    })
382}
383
384fn build_temporal_snapshots(imports: &[ProjectionImportLogEntry]) -> Vec<TemporalReplaySnapshot> {
385    let mut snapshots = imports
386        .iter()
387        .filter_map(|row| {
388            row.kernel_payload_json.clone().and_then(|payload| {
389                serde_json::from_value::<ProjectionImportBatchV3>(payload)
390                    .ok()
391                    .map(|batch| TemporalReplaySnapshot {
392                        recorded_at: row.imported_at.clone(),
393                        batch,
394                    })
395            })
396        })
397        .collect::<Vec<_>>();
398    snapshots.sort_by(|left, right| left.recorded_at.cmp(&right.recorded_at));
399    snapshots
400}
401
402fn build_scope_health_summary(
403    claim_versions: &[ProjectionClaimVersion],
404    relation_versions: &[ProjectionRelationVersion],
405    episodes: &[ProjectionEpisode],
406    scheduled: Option<&ScheduledExecution>,
407    compiled: Option<&CompileOutput>,
408    import_log: Option<&ProjectionImportLogEntry>,
409    _staleness_threshold_secs: &u64,
410) -> ScopeHealthSummary {
411    let fragile_node_count = scheduled
412        .map(|scheduled| {
413            scheduled
414                .execution
415                .node_beliefs
416                .iter()
417                .filter(|belief| belief.belief_micros < 900_000)
418                .count()
419        })
420        .unwrap_or(0);
421    let syndrome_count = scheduled
422        .map(|scheduled| scheduled.execution.syndromes.len())
423        .unwrap_or(0);
424    let degradation_count = compiled
425        .map(|compiled| compiled.degradations.len())
426        .unwrap_or(0);
427    let unverified_claim_count = claim_versions
428        .iter()
429        .filter(|claim| claim.claim_state == "pending_review")
430        .count();
431    let supersession_candidate_count = claim_versions
432        .iter()
433        .filter(|claim| claim.supersedes_claim_version_id.is_some())
434        .count();
435
436    ScopeHealthSummary {
437        total_claim_versions: claim_versions.len(),
438        total_relation_versions: relation_versions.len(),
439        total_episodes: episodes.len(),
440        fragile_node_count,
441        syndrome_count,
442        degradation_count,
443        unverified_claim_count,
444        supersession_candidate_count,
445        last_import_at: import_log.map(|row| row.imported_at.clone()),
446    }
447}
448
449fn is_scope_stale(
450    _scope: &Scope,
451    import_log: Option<&ProjectionImportLogEntry>,
452    threshold_secs: u64,
453) -> bool {
454    if threshold_secs == 0 {
455        return false;
456    }
457    let Some(import_log) = import_log else {
458        return true;
459    };
460    let Ok(imported_at) = DateTime::parse_from_rfc3339(&import_log.imported_at) else {
461        return false;
462    };
463    let age_secs = Utc::now()
464        .signed_duration_since(imported_at.with_timezone(&Utc))
465        .num_seconds();
466    age_secs >= threshold_secs as i64
467}
468
469fn degradation_kind_name(marker: &ConstraintDegradation) -> &'static str {
470    match marker {
471        ConstraintDegradation::MissingClaimFamily => "missing_claim_family",
472        ConstraintDegradation::MissingAssertionGroup => "missing_assertion_group",
473        ConstraintDegradation::MissingRelationGroup => "missing_relation_group",
474        ConstraintDegradation::ThinExport => "thin_export",
475    }
476}
477
478/// Inspects the configured observation paths without opening the underlying stores.
479pub fn inspect_observation_paths(config: &LoopConfig) -> ObservationPaths {
480    ObservationPaths {
481        namespace: config.scope.namespace.clone(),
482        workspace_path: resolve_display_path(&config.workspace_path),
483        memory_dir: resolve_display_path(&config.memory_dir),
484        forge_db_path: resolve_display_path(&config.forge_db_path),
485        memory_dir_state: inspect_memory_dir(&config.memory_dir),
486        forge_db_state: inspect_forge_db(&config.forge_db_path),
487    }
488}
489
490fn build_observation_status(
491    scope: &Scope,
492    paths: &ObservationPaths,
493    source_inventory: &SourceInventory,
494    forge_evidence_inventory: &ForgeEvidenceInventory,
495    recent_imports: &[ProjectionImportLogEntry],
496    all_recent_imports: &[ProjectionImportLogEntry],
497    completed_import_found: bool,
498) -> ObservationStatus {
499    let import_records_found = !recent_imports.is_empty();
500    let other_import_namespaces = all_recent_imports
501        .iter()
502        .filter_map(|row| {
503            if row.scope_namespace != scope.namespace {
504                Some(row.scope_namespace.clone())
505            } else {
506                None
507            }
508        })
509        .collect::<BTreeSet<_>>()
510        .into_iter()
511        .collect::<Vec<_>>();
512    let base = |disposition: ObservationDisposition,
513                import_record_disposition: ImportRecordDisposition,
514                exact_next_step: String| ObservationStatus {
515        disposition,
516        namespace_queried: scope.namespace.clone(),
517        resolved_workspace_path: paths.workspace_path.clone(),
518        supported_file_count: source_inventory.supported_file_count,
519        imported_file_count: source_inventory.imported_current_state.file_count,
520        imported_chunk_count: source_inventory.imported_current_state.chunk_count,
521        imported_symbol_count: source_inventory.imported_current_state.symbol_count,
522        bootstrap_richness: source_inventory.imported_current_state.richness,
523        available_forge_bundle_count: forge_evidence_inventory.available_bundle_count,
524        import_record_disposition,
525        import_records_found,
526        exact_next_step,
527        other_import_namespaces: other_import_namespaces.clone(),
528    };
529
530    if matches!(paths.forge_db_state, PathAvailability::Invalid) {
531        return base(
532            ObservationDisposition::StorageCorrupt,
533            ImportRecordDisposition::StorageCorrupt,
534            format!(
535                "Repair the configured canonical storage before running the pilot. Forge DB: {} ({:?}); memory folder: {} ({:?}).",
536                paths.forge_db_path,
537                paths.forge_db_state,
538                paths.memory_dir,
539                paths.memory_dir_state
540            ),
541        );
542    }
543
544    if matches!(paths.forge_db_state, PathAvailability::Missing) {
545        return base(
546            ObservationDisposition::StorageUnavailable,
547            ImportRecordDisposition::StorageUnavailable,
548            format!(
549                "Fix or bootstrap the configured canonical storage paths before running the pilot. Forge DB: {} ({:?}); memory folder: {} ({:?}).",
550                paths.forge_db_path,
551                paths.forge_db_state,
552                paths.memory_dir,
553                paths.memory_dir_state
554            ),
555        );
556    }
557
558    if !completed_import_found && !other_import_namespaces.is_empty() {
559        return base(
560            ObservationDisposition::NamespaceMismatch,
561            ImportRecordDisposition::NamespaceMismatch,
562            format!(
563                "No completed canonical import was found for namespace {}. Recent imports exist for other namespaces: {}. Run the canonical Forge export -> bridge -> semantic-memory import path for the requested namespace or switch the queried namespace.",
564                scope.namespace,
565                other_import_namespaces.join(", ")
566            ),
567        );
568    }
569
570    if source_inventory.supported_file_count > 0 && !completed_import_found {
571        let import_command = format_import_command(scope, paths);
572        let run_command = format_run_command(scope, paths);
573        let bootstrap_command = format_bootstrap_source_command(scope, paths);
574        return base(
575            ObservationDisposition::ImportRequired,
576            ImportRecordDisposition::NeverImported,
577            if forge_evidence_inventory.available_bundle_count > 0 {
578                format!(
579                    "Forge has {} evidence bundle(s) available. Run {} and then rerun {}.",
580                    forge_evidence_inventory.available_bundle_count, import_command, run_command
581                )
582            } else {
583                format!(
584                    "No completed canonical import or exportable Forge evidence bundle exists yet for namespace {}. Run {} to bootstrap thin imported source state, or populate Forge through the canonical verification/export lane and then run {}.",
585                    scope.namespace, bootstrap_command, import_command
586                )
587            },
588        );
589    }
590
591    if source_inventory.supported_file_count == 0 && !completed_import_found {
592        return base(
593            ObservationDisposition::EmptyWorkspace,
594            ImportRecordDisposition::NeverImported,
595            "No supported source files and no completed canonical imports were found for this namespace."
596                .into(),
597        );
598    }
599
600    base(
601        ObservationDisposition::Ready,
602        ImportRecordDisposition::Found,
603        "No bootstrap step is required; the pilot can use canonical imported state.".into(),
604    )
605}
606
607fn discover_source_inventory(config: &LoopConfig, paths: &ObservationPaths) -> SourceInventory {
608    let workspace_path = resolve_runtime_path(&config.workspace_path);
609    let memory_dir = resolve_runtime_path(&config.memory_dir);
610    SourceInventory {
611        supported_file_count: count_supported_source_files(
612            &workspace_path,
613            &memory_dir,
614            matches!(paths.memory_dir_state, PathAvailability::Present),
615        ),
616        imported_current_state: BootstrapCurrentState::default(),
617    }
618}
619
620fn discover_forge_evidence_inventory(
621    config: &LoopConfig,
622    paths: &ObservationPaths,
623) -> ForgeEvidenceInventory {
624    if !matches!(paths.forge_db_state, PathAvailability::Present) {
625        return ForgeEvidenceInventory {
626            available_bundle_count: 0,
627        };
628    }
629
630    let available_bundle_count = ForgeStore::open(Path::new(&config.forge_db_path))
631        .ok()
632        .and_then(|store| store.count_evidence_bundles().ok())
633        .unwrap_or(0);
634
635    ForgeEvidenceInventory {
636        available_bundle_count,
637    }
638}
639
640fn format_import_command(scope: &Scope, paths: &ObservationPaths) -> String {
641    format!(
642        "`cargo run -p forge-pilot -- import --namespace {} --workspace-path {} --memory-dir {} --forge-db {}`",
643        shell_quote(&scope.namespace),
644        shell_quote(&paths.workspace_path),
645        shell_quote(&paths.memory_dir),
646        shell_quote(&paths.forge_db_path)
647    )
648}
649
650fn format_run_command(scope: &Scope, paths: &ObservationPaths) -> String {
651    format!(
652        "`cargo run -p forge-pilot -- run --namespace {} --workspace-path {} --memory-dir {} --forge-db {}`",
653        shell_quote(&scope.namespace),
654        shell_quote(&paths.workspace_path),
655        shell_quote(&paths.memory_dir),
656        shell_quote(&paths.forge_db_path)
657    )
658}
659
660fn format_bootstrap_source_command(scope: &Scope, paths: &ObservationPaths) -> String {
661    format!(
662        "`cargo run -p forge-pilot -- bootstrap-source --namespace {} --workspace {} --memory-dir {} --forge-db {}`",
663        shell_quote(&scope.namespace),
664        shell_quote(&paths.workspace_path),
665        shell_quote(&paths.memory_dir),
666        shell_quote(&paths.forge_db_path)
667    )
668}
669
670fn shell_quote(value: &str) -> String {
671    format!("'{}'", value.replace('\'', "'\"'\"'"))
672}
673
674fn count_supported_source_files(
675    workspace_path: &Path,
676    memory_dir: &Path,
677    memory_dir_present: bool,
678) -> usize {
679    let mut total = 0usize;
680    let mut stack = vec![workspace_path.to_path_buf()];
681    let excluded_memory_dir = memory_dir_present
682        .then(|| fs::canonicalize(memory_dir).ok())
683        .flatten();
684
685    while let Some(path) = stack.pop() {
686        let Ok(entries) = fs::read_dir(&path) else {
687            continue;
688        };
689        for entry in entries.flatten() {
690            let path = entry.path();
691            let Ok(file_type) = entry.file_type() else {
692                continue;
693            };
694            if file_type.is_dir() {
695                if should_skip_workspace_dir(&path, excluded_memory_dir.as_deref()) {
696                    continue;
697                }
698                stack.push(path);
699                continue;
700            }
701            if file_type.is_file() && is_supported_source_file_path(&path) {
702                total += 1;
703            }
704        }
705    }
706
707    total
708}
709
710fn inspect_memory_dir(path: &str) -> PathAvailability {
711    let path = resolve_runtime_path(path);
712    if !path.exists() {
713        return PathAvailability::Missing;
714    }
715    if path.is_dir() {
716        PathAvailability::Present
717    } else {
718        PathAvailability::Invalid
719    }
720}
721
722fn inspect_forge_db(path: &str) -> PathAvailability {
723    let path = resolve_runtime_path(path);
724    if !path.exists() {
725        return PathAvailability::Missing;
726    }
727    match rusqlite::Connection::open(&path)
728        .and_then(|conn| conn.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0)))
729    {
730        Ok(_) => PathAvailability::Present,
731        Err(_) => PathAvailability::Invalid,
732    }
733}
734
735fn resolve_display_path(raw: &str) -> String {
736    let joined = resolve_runtime_path(raw);
737    fs::canonicalize(&joined)
738        .unwrap_or(joined)
739        .to_string_lossy()
740        .to_string()
741}
742
743fn resolve_runtime_path(raw: &str) -> PathBuf {
744    let expanded = expand_user_path(raw);
745    if expanded.is_absolute() {
746        expanded
747    } else {
748        std::env::current_dir()
749            .unwrap_or_else(|_| PathBuf::from("."))
750            .join(expanded)
751    }
752}
753
754fn expand_user_path(raw: &str) -> PathBuf {
755    if raw == "~" {
756        return std::env::var_os("HOME")
757            .map(PathBuf::from)
758            .unwrap_or_else(|| PathBuf::from(raw));
759    }
760    if let Some(rest) = raw.strip_prefix("~/") {
761        return std::env::var_os("HOME")
762            .map(|home| PathBuf::from(home).join(rest))
763            .unwrap_or_else(|| PathBuf::from(raw));
764    }
765    PathBuf::from(raw)
766}