Skip to main content

oxios_kernel/mount/
mod.rs

1//! Mount module: path-alias system (RFC-025).
2//!
3//! A **Mount** is a lightweight name bound to one or more filesystem paths
4//! (`oxios` → `/Volumes/MERCURY/PROJECTS/oxios`). It is the path-alias role
5//! that RFC-011's `Project` conflated with memory partitioning.
6//!
7//! The agent explores a Mount's paths with tools (`ls`/`read`/`grep`) and
8//! accumulates `auto_description` / `auto_meta` over time. Mounts are living
9//! objects — they are refreshed during sessions, on marker drift, and during
10//! Dream consolidation (RFC-008).
11//!
12//! ## Structure
13//!
14//! - `mod.rs` — `Mount`, `MountMeta`, `MountSource`, `MountId` (this file)
15//! - `mount_db.rs` — SQLite persistence (`mounts` table)
16//! - `manager.rs` — `MountManager` (CRUD, lookup, detection, touch)
17//! - `detection.rs` — `detect_mounts` (name/path/meta matching)
18
19pub mod detection;
20pub mod manager;
21pub mod meta_detection;
22pub mod mount_db;
23pub mod path_promotion;
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::time::SystemTime;
28
29use chrono::{DateTime, Utc};
30use serde::{Deserialize, Serialize};
31use uuid::Uuid;
32
33// ── Re-exports ──────────────────────────────────────────────
34pub use detection::{DetectionResult, detect_mounts, extract_path, find_by_id, find_by_name};
35pub use manager::{MountManager, MountManagerError};
36pub use meta_detection::{detect_meta, snapshot_markers};
37pub use path_promotion::{PathFrequency, PromotionConfig};
38
39/// Unique identifier for a Mount.
40pub type MountId = Uuid;
41
42/// How a Mount was registered.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum MountSource {
46    /// User explicitly created via UI/CLI.
47    #[default]
48    Manual,
49    /// OS auto-detected from a path in the conversation.
50    AutoDetected,
51    /// RFC-025 Phase 5: auto-promoted from a frequently-used path.
52    /// Created by the background scanner when a path crosses the frequency
53    /// threshold. Distinguishable in the UI so users can review/prune them.
54    AutoPromoted,
55}
56
57impl std::fmt::Display for MountSource {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            MountSource::Manual => write!(f, "manual"),
61            MountSource::AutoDetected => write!(f, "auto_detected"),
62            MountSource::AutoPromoted => write!(f, "auto_promoted"),
63        }
64    }
65}
66
67/// Auto-detected metadata, written/refined by the agent as it explores.
68///
69/// Replaces RFC-011's manual `tags`. Seeded by cheap heuristics on marker
70/// files (`Cargo.toml`, `package.json`, …) at drift-detection time, then
71/// refined by the agent during enrichment.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct MountMeta {
74    /// Detected programming languages (e.g. `["rust", "typescript"]`).
75    #[serde(default)]
76    pub languages: Vec<String>,
77    /// Detected stack / key dependencies (e.g. `["tokio", "axum", "react"]`).
78    #[serde(default)]
79    pub stack: Vec<String>,
80    /// Detected marker files (e.g. `["Cargo.toml", "AGENTS.md"]`).
81    #[serde(default)]
82    pub markers: Vec<String>,
83    /// One-line derived summary.
84    #[serde(default)]
85    pub summary: String,
86}
87
88/// A path alias: a name bound to one or more filesystem paths.
89///
90/// The agent explores the path(s) with tools and writes
91/// [`auto_description`](Self::auto_description) /
92/// [`auto_meta`](Self::auto_meta) over time. `paths[0]` is the CWD when this
93/// Mount is the session's primary.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Mount {
96    /// Unique identifier.
97    pub id: MountId,
98    /// Human-readable name (unique, e.g. "oxios").
99    pub name: String,
100    /// Filesystem paths. `paths[0]` is CWD when this Mount is primary.
101    /// Must contain ≥1 path for a code Mount.
102    pub paths: Vec<PathBuf>,
103    /// Agent-explored description; updated over time.
104    #[serde(default)]
105    pub auto_description: String,
106    /// Auto-detected stack / languages / structure.
107    #[serde(default)]
108    pub auto_meta: MountMeta,
109    /// How this Mount was registered.
110    pub source: MountSource,
111
112    // ── Enrichment state (RFC-025 §Enrichment Triggers) ──
113    /// Marker-file mtime at the last enrichment, for drift detection.
114    /// Keys are marker file paths.
115    #[serde(default)]
116    pub last_marker_snapshot: HashMap<PathBuf, SystemTime>,
117    /// Drift detected; the agent is nudged to refresh.
118    #[serde(default)]
119    pub enrichment_pending: bool,
120    /// When this Mount was last enriched by the agent.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub last_enriched_at: Option<DateTime<Utc>>,
123
124    /// When this Mount was created.
125    pub created_at: DateTime<Utc>,
126    /// When this Mount was last modified.
127    pub updated_at: DateTime<Utc>,
128    /// When this Mount was last active (used in a session).
129    pub last_active_at: DateTime<Utc>,
130}
131
132impl Mount {
133    /// Create a new Mount with the given name and source.
134    pub fn new(name: impl Into<String>, source: MountSource) -> Self {
135        let now = Utc::now();
136        Self {
137            id: MountId::new_v4(),
138            name: name.into(),
139            paths: Vec::new(),
140            auto_description: String::new(),
141            auto_meta: MountMeta::default(),
142            source,
143            last_marker_snapshot: HashMap::new(),
144            enrichment_pending: false,
145            last_enriched_at: None,
146            created_at: now,
147            updated_at: now,
148            last_active_at: now,
149        }
150    }
151
152    /// Create a minimal Mount from a name + single path (the common
153    /// "name + path only" creation flow from RFC-025).
154    pub fn from_name_and_path(name: impl Into<String>, path: PathBuf) -> Self {
155        let mut mount = Self::new(name, MountSource::Manual);
156        mount.paths.push(path);
157        mount
158    }
159
160    /// Record that this Mount was used in a session.
161    pub fn touch(&mut self) {
162        self.last_active_at = Utc::now();
163    }
164
165    /// Whether this Mount has any filesystem paths.
166    pub fn has_paths(&self) -> bool {
167        !self.paths.is_empty()
168    }
169
170    /// Get the primary path (CWD source when this Mount is primary).
171    pub fn primary_path(&self) -> Option<&PathBuf> {
172        self.paths.first()
173    }
174
175    /// A one-line display summary, preferring the agent-written summary then
176    /// the auto-meta summary, falling back to the detected languages.
177    pub fn summary_line(&self) -> String {
178        if !self.auto_meta.summary.is_empty() {
179            return self.auto_meta.summary.clone();
180        }
181        if !self.auto_description.is_empty() {
182            // First non-empty line, trimmed.
183            return self
184                .auto_description
185                .lines()
186                .find(|l| !l.trim().is_empty())
187                .unwrap_or("")
188                .trim()
189                .to_string();
190        }
191        if !self.auto_meta.languages.is_empty() {
192            return self.auto_meta.languages.join(", ");
193        }
194        String::new()
195    }
196
197    /// Get the display tag (e.g. "[🔧 oxios]").
198    pub fn tag(&self) -> String {
199        format!("[🔧 {}]", self.name)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_mount_new() {
209        let m = Mount::new("oxios", MountSource::Manual);
210        assert_eq!(m.name, "oxios");
211        assert_eq!(m.source, MountSource::Manual);
212        assert!(m.paths.is_empty());
213        assert!(!m.enrichment_pending);
214    }
215
216    #[test]
217    fn test_mount_from_name_and_path() {
218        let m =
219            Mount::from_name_and_path("oxios", PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
220        assert_eq!(m.name, "oxios");
221        assert!(m.has_paths());
222        assert_eq!(
223            m.primary_path(),
224            Some(&PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
225        );
226    }
227
228    #[test]
229    fn test_mount_tag() {
230        let m = Mount::new("oxios", MountSource::Manual);
231        assert_eq!(m.tag(), "[🔧 oxios]");
232    }
233
234    #[test]
235    fn test_summary_line_prefers_meta_summary() {
236        let mut m = Mount::new("oxios", MountSource::Manual);
237        m.auto_description = "Detailed description.\nSecond line.".to_string();
238        m.auto_meta.summary = "Agent OS in Rust".to_string();
239        m.auto_meta.languages = vec!["rust".to_string()];
240        assert_eq!(m.summary_line(), "Agent OS in Rust");
241    }
242
243    #[test]
244    fn test_summary_line_falls_back_to_description() {
245        let mut m = Mount::new("oxios", MountSource::Manual);
246        m.auto_description = "First line.\nSecond.".to_string();
247        assert_eq!(m.summary_line(), "First line.");
248    }
249
250    #[test]
251    fn test_summary_line_falls_back_to_languages() {
252        let mut m = Mount::new("oxios", MountSource::Manual);
253        m.auto_meta.languages = vec!["rust".to_string(), "typescript".to_string()];
254        assert_eq!(m.summary_line(), "rust, typescript");
255    }
256}