1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38pub struct ObservationDegradation {
39 pub kind: String,
40 pub detail: String,
41}
42
43#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95pub struct ForgeEvidenceInventory {
96 pub available_bundle_count: usize,
97}
98
99#[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#[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#[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 pub fn missing_kernel_payload(&self) -> bool {
172 self.degradations
173 .iter()
174 .any(|degradation| degradation.kind == "missing_kernel_payload")
175 }
176
177 pub fn thin_export_active(&self) -> bool {
179 self.degradations
180 .iter()
181 .any(|degradation| degradation.kind == "thin_export")
182 }
183}
184
185pub 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
478pub 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}