Skip to main content

coding_agent_search/sources/
provenance.rs

1//! Provenance types for tracking conversation origins.
2//!
3//! This module defines the data model for tracking where conversations come from.
4//! These types are used throughout cass: storage, indexing, search, CLI, TUI.
5//!
6//! # Key Types
7//!
8//! - [`SourceKind`]: The type of source (local, SSH, etc.)
9//! - [`Source`]: A registered source in the system (stored in SQLite)
10//! - [`Origin`]: Per-conversation provenance metadata
11//!
12//! # Example
13//!
14//! ```rust
15//! use coding_agent_search::sources::provenance::{Origin, SourceKind, LOCAL_SOURCE_ID};
16//!
17//! // Create origin for a local conversation
18//! let local_origin = Origin::local();
19//! assert_eq!(local_origin.source_id, LOCAL_SOURCE_ID);
20//!
21//! // Create origin for a remote conversation
22//! let remote_origin = Origin::remote("work-laptop");
23//! assert!(remote_origin.is_remote());
24//! ```
25
26use serde::{Deserialize, Serialize};
27
28// Re-export core provenance types from franken_agent_detection.
29pub use franken_agent_detection::{LOCAL_SOURCE_ID, Origin, SourceKind};
30
31const SOURCE_FILTER_ALL: &str = "all";
32const SOURCE_FILTER_LOCAL: &str = "local";
33const SOURCE_FILTER_REMOTE: &str = "remote";
34
35/// A registered source in the system.
36///
37/// This struct represents a source record as stored in SQLite.
38/// It's different from [`super::config::SourceDefinition`] which is
39/// the user-facing configuration for how to connect to a source.
40///
41/// # Fields
42///
43/// - `id`: Stable, user-friendly identifier (e.g., "local", "work-laptop")
44/// - `kind`: The type of source (local, ssh, etc.)
45/// - `host_label`: Display label for UI (often SSH alias or hostname)
46/// - `machine_id`: Optional stable machine identifier
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Source {
49    /// Stable, user-friendly identifier.
50    /// Examples: "local", "work-laptop", "home-server"
51    pub id: String,
52
53    /// What type of source this is.
54    pub kind: SourceKind,
55
56    /// Display label for UI (often SSH alias or hostname).
57    /// May be None for local source.
58    pub host_label: Option<String>,
59
60    /// Optional stable machine identifier (can be hashed for privacy).
61    pub machine_id: Option<String>,
62
63    /// Platform hint (macos, linux, windows).
64    pub platform: Option<String>,
65
66    /// Extra configuration as JSON (SSH params, path rewrites, etc.).
67    pub config_json: Option<serde_json::Value>,
68
69    /// When this source was first registered.
70    pub created_at: Option<i64>,
71
72    /// When this source was last updated.
73    pub updated_at: Option<i64>,
74}
75
76impl Source {
77    /// Create a new local source.
78    pub fn local() -> Self {
79        Self {
80            id: LOCAL_SOURCE_ID.to_string(),
81            kind: SourceKind::Local,
82            host_label: None,
83            machine_id: None,
84            platform: None,
85            config_json: None,
86            created_at: None,
87            updated_at: None,
88        }
89    }
90
91    /// Create a new remote source.
92    pub fn remote(id: impl Into<String>, host_label: impl Into<String>) -> Self {
93        Self {
94            id: id.into(),
95            kind: SourceKind::Ssh,
96            host_label: Some(host_label.into()),
97            machine_id: None,
98            platform: None,
99            config_json: None,
100            created_at: None,
101            updated_at: None,
102        }
103    }
104
105    /// Check if this is a remote source.
106    pub fn is_remote(&self) -> bool {
107        self.kind.is_remote()
108    }
109
110    /// Check if this is the local source.
111    pub fn is_local(&self) -> bool {
112        self.id == LOCAL_SOURCE_ID && self.kind == SourceKind::Local
113    }
114
115    /// Get a display label for this source.
116    pub fn display_label(&self) -> &str {
117        self.host_label.as_deref().unwrap_or(&self.id)
118    }
119}
120
121impl Default for Source {
122    fn default() -> Self {
123        Self::local()
124    }
125}
126
127/// Filter for searching by source.
128///
129/// Used in search queries to filter results by their origin.
130#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
131#[serde(rename_all = "snake_case")]
132pub enum SourceFilter {
133    /// Match all sources (no filtering).
134    #[default]
135    All,
136    /// Match only local sources.
137    Local,
138    /// Match only remote sources (any SSH source).
139    Remote,
140    /// Match a specific source by ID.
141    SourceId(String),
142}
143
144impl SourceFilter {
145    /// Parse a source filter from a string.
146    ///
147    /// - "all" or "*" → All
148    /// - "local" → Local
149    /// - "remote" → Remote
150    /// - anything else → SourceId
151    pub fn parse(s: &str) -> Self {
152        let trimmed = s.trim();
153        match trimmed.to_ascii_lowercase().as_str() {
154            "" | SOURCE_FILTER_ALL | "*" => Self::All,
155            SOURCE_FILTER_LOCAL => Self::Local,
156            SOURCE_FILTER_REMOTE => Self::Remote,
157            _ => Self::SourceId(trimmed.to_string()),
158        }
159    }
160
161    /// Check if an origin matches this filter.
162    pub fn matches(&self, origin: &Origin) -> bool {
163        match self {
164            Self::All => true,
165            Self::Local => origin.is_local(),
166            Self::Remote => origin.is_remote(),
167            Self::SourceId(id) => {
168                let filter_id = id.trim();
169                !filter_id.is_empty() && origin.source_id.trim() == filter_id
170            }
171        }
172    }
173
174    /// Check if this filter allows any source.
175    pub fn is_all(&self) -> bool {
176        matches!(self, Self::All)
177    }
178
179    /// Cycle to the next filter in sequence (for F11 hotkey).
180    ///
181    /// Cycle order: All → Local → Remote → All
182    /// SourceId variants reset to All.
183    pub fn cycle(&self) -> Self {
184        match self {
185            Self::All => Self::Local,
186            Self::Local => Self::Remote,
187            Self::Remote => Self::All,
188            Self::SourceId(_) => Self::All,
189        }
190    }
191}
192
193impl std::fmt::Display for SourceFilter {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            Self::All => f.write_str(SOURCE_FILTER_ALL),
197            Self::Local => f.write_str(SOURCE_FILTER_LOCAL),
198            Self::Remote => f.write_str(SOURCE_FILTER_REMOTE),
199            Self::SourceId(id) => f.write_str(id),
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_source_kind_default() {
210        assert_eq!(SourceKind::default(), SourceKind::Local);
211    }
212
213    #[test]
214    fn test_source_kind_is_remote() {
215        assert!(!SourceKind::Local.is_remote());
216        assert!(SourceKind::Ssh.is_remote());
217    }
218
219    #[test]
220    fn test_source_kind_display() {
221        assert_eq!(SourceKind::Local.to_string(), "local");
222        assert_eq!(SourceKind::Ssh.to_string(), "ssh");
223    }
224
225    #[test]
226    fn test_source_kind_parse() {
227        assert_eq!(SourceKind::parse("local"), Some(SourceKind::Local));
228        assert_eq!(SourceKind::parse("LOCAL"), Some(SourceKind::Local));
229        assert_eq!(SourceKind::parse("ssh"), Some(SourceKind::Ssh));
230        assert_eq!(SourceKind::parse("SSH"), Some(SourceKind::Ssh));
231        assert_eq!(SourceKind::parse("unknown"), None);
232    }
233
234    #[test]
235    fn test_source_kind_serialization() {
236        assert_eq!(
237            serde_json::to_string(&SourceKind::Local).unwrap(),
238            "\"local\""
239        );
240        assert_eq!(serde_json::to_string(&SourceKind::Ssh).unwrap(), "\"ssh\"");
241    }
242
243    #[test]
244    fn test_source_kind_deserialization() {
245        assert_eq!(
246            serde_json::from_str::<SourceKind>("\"local\"").unwrap(),
247            SourceKind::Local
248        );
249        assert_eq!(
250            serde_json::from_str::<SourceKind>("\"ssh\"").unwrap(),
251            SourceKind::Ssh
252        );
253    }
254
255    #[test]
256    fn test_source_local() {
257        let source = Source::local();
258        assert_eq!(source.id, LOCAL_SOURCE_ID);
259        assert_eq!(source.kind, SourceKind::Local);
260        assert!(source.is_local());
261        assert!(!source.is_remote());
262    }
263
264    #[test]
265    fn test_source_remote() {
266        let source = Source::remote("laptop", "user@laptop.local");
267        assert_eq!(source.id, "laptop");
268        assert_eq!(source.kind, SourceKind::Ssh);
269        assert_eq!(source.host_label, Some("user@laptop.local".to_string()));
270        assert!(source.is_remote());
271        assert!(!source.is_local());
272    }
273
274    #[test]
275    fn test_source_display_label() {
276        let local = Source::local();
277        assert_eq!(local.display_label(), "local");
278
279        let remote = Source::remote("laptop", "user@laptop.local");
280        assert_eq!(remote.display_label(), "user@laptop.local");
281    }
282
283    #[test]
284    fn test_source_default() {
285        let source = Source::default();
286        assert!(source.is_local());
287    }
288
289    #[test]
290    fn test_origin_local() {
291        let origin = Origin::local();
292        assert_eq!(origin.source_id, LOCAL_SOURCE_ID);
293        assert_eq!(origin.kind, SourceKind::Local);
294        assert!(origin.is_local());
295        assert!(!origin.is_remote());
296    }
297
298    #[test]
299    fn test_origin_remote() {
300        let origin = Origin::remote("laptop");
301        assert_eq!(origin.source_id, "laptop");
302        assert_eq!(origin.kind, SourceKind::Ssh);
303        assert!(origin.is_remote());
304        assert!(!origin.is_local());
305    }
306
307    #[test]
308    fn test_origin_remote_with_host() {
309        let origin = Origin::remote_with_host("laptop", "user@laptop.local");
310        assert_eq!(origin.source_id, "laptop");
311        assert_eq!(origin.host, Some("user@laptop.local".to_string()));
312    }
313
314    #[test]
315    fn test_origin_display_label() {
316        let local = Origin::local();
317        assert_eq!(local.display_label(), "local");
318
319        let remote = Origin::remote("laptop");
320        assert_eq!(remote.display_label(), "laptop (remote)");
321
322        let remote_with_host = Origin::remote_with_host("laptop", "user@laptop.local");
323        assert_eq!(
324            remote_with_host.display_label(),
325            "user@laptop.local (remote)"
326        );
327    }
328
329    #[test]
330    fn test_origin_short_label() {
331        let local = Origin::local();
332        assert_eq!(local.short_label(), "local");
333
334        let remote = Origin::remote_with_host("laptop", "user@laptop.local");
335        assert_eq!(remote.short_label(), "user@laptop.local");
336    }
337
338    #[test]
339    fn test_origin_default() {
340        let origin = Origin::default();
341        assert!(origin.is_local());
342    }
343
344    #[test]
345    fn test_origin_equality() {
346        let a = Origin::local();
347        let b = Origin::local();
348        assert_eq!(a, b);
349
350        let c = Origin::remote("laptop");
351        let d = Origin::remote("laptop");
352        assert_eq!(c, d);
353
354        assert_ne!(a, c);
355    }
356
357    #[test]
358    fn test_origin_serialization_roundtrip() {
359        let original = Origin::remote_with_host("laptop", "user@host");
360        let json = serde_json::to_string(&original).unwrap();
361        let deserialized: Origin = serde_json::from_str(&json).unwrap();
362        assert_eq!(original, deserialized);
363    }
364
365    #[test]
366    fn test_source_filter_parse() {
367        assert_eq!(SourceFilter::parse(SOURCE_FILTER_ALL), SourceFilter::All);
368        assert_eq!(SourceFilter::parse("ALL"), SourceFilter::All);
369        assert_eq!(SourceFilter::parse("*"), SourceFilter::All);
370        assert_eq!(
371            SourceFilter::parse(SOURCE_FILTER_LOCAL),
372            SourceFilter::Local
373        );
374        assert_eq!(SourceFilter::parse("LOCAL"), SourceFilter::Local);
375        assert_eq!(
376            SourceFilter::parse(SOURCE_FILTER_REMOTE),
377            SourceFilter::Remote
378        );
379        assert_eq!(SourceFilter::parse("REMOTE"), SourceFilter::Remote);
380        assert_eq!(
381            SourceFilter::parse("laptop"),
382            SourceFilter::SourceId("laptop".to_string())
383        );
384    }
385
386    #[test]
387    fn test_source_filter_parse_trims_whitespace() {
388        assert_eq!(SourceFilter::parse("   local   "), SourceFilter::Local);
389        assert_eq!(SourceFilter::parse("   REMOTE	"), SourceFilter::Remote);
390        assert_eq!(
391            SourceFilter::parse("   laptop-01   "),
392            SourceFilter::SourceId("laptop-01".to_string())
393        );
394        assert_eq!(SourceFilter::parse("   	  "), SourceFilter::All);
395    }
396
397    #[test]
398    fn test_source_filter_matches() {
399        let local = Origin::local();
400        let remote = Origin::remote("laptop");
401        let mut whitespace_remote = Origin::remote("laptop");
402        whitespace_remote.source_id = "  laptop  ".to_string();
403
404        assert!(SourceFilter::All.matches(&local));
405        assert!(SourceFilter::All.matches(&remote));
406
407        assert!(SourceFilter::Local.matches(&local));
408        assert!(!SourceFilter::Local.matches(&remote));
409
410        assert!(!SourceFilter::Remote.matches(&local));
411        assert!(SourceFilter::Remote.matches(&remote));
412
413        assert!(SourceFilter::SourceId("laptop".to_string()).matches(&remote));
414        assert!(SourceFilter::SourceId("  laptop  ".to_string()).matches(&whitespace_remote));
415        assert!(!SourceFilter::SourceId("laptop".to_string()).matches(&local));
416        assert!(!SourceFilter::SourceId("other".to_string()).matches(&remote));
417        assert!(!SourceFilter::SourceId("   ".to_string()).matches(&remote));
418    }
419
420    #[test]
421    fn test_source_filter_display() {
422        assert_eq!(SourceFilter::All.to_string(), SOURCE_FILTER_ALL);
423        assert_eq!(SourceFilter::Local.to_string(), SOURCE_FILTER_LOCAL);
424        assert_eq!(SourceFilter::Remote.to_string(), SOURCE_FILTER_REMOTE);
425        assert_eq!(
426            SourceFilter::SourceId("laptop".to_string()).to_string(),
427            "laptop"
428        );
429    }
430
431    #[test]
432    fn test_source_filter_default() {
433        assert_eq!(SourceFilter::default(), SourceFilter::All);
434    }
435
436    // =================================================================
437    // F11 Source Filter Cycle Tests (P4.3 TUI behavior)
438    // =================================================================
439
440    #[test]
441    fn test_source_filter_cycle_transitions() {
442        for (case, filter, expected) in [
443            ("all to local", SourceFilter::All, SourceFilter::Local),
444            ("local to remote", SourceFilter::Local, SourceFilter::Remote),
445            ("remote to all", SourceFilter::Remote, SourceFilter::All),
446            (
447                "specific to all",
448                SourceFilter::SourceId("laptop".to_string()),
449                SourceFilter::All,
450            ),
451        ] {
452            assert_eq!(filter.cycle(), expected, "{case}");
453        }
454    }
455
456    #[test]
457    fn test_source_filter_full_cycle() {
458        // Complete F11 cycle: All → Local → Remote → All
459        let filter = SourceFilter::All;
460        let after_one = filter.cycle();
461        let after_two = after_one.cycle();
462        let after_three = after_two.cycle();
463
464        assert_eq!(after_one, SourceFilter::Local);
465        assert_eq!(after_two, SourceFilter::Remote);
466        assert_eq!(after_three, SourceFilter::All);
467    }
468
469    #[test]
470    fn test_source_filter_cycle_is_idempotent_for_specific() {
471        // Multiple cycles from SourceId should always go to All first
472        let filter = SourceFilter::SourceId("work-laptop".to_string());
473        let cycled = filter.cycle();
474        assert_eq!(cycled, SourceFilter::All);
475
476        // Then continue normal cycle
477        assert_eq!(cycled.cycle(), SourceFilter::Local);
478    }
479
480    #[test]
481    fn test_source_filter_cycle_preserves_type_invariants() {
482        // Cycling should never produce a SourceId variant
483        let filters = [
484            SourceFilter::All,
485            SourceFilter::Local,
486            SourceFilter::Remote,
487            SourceFilter::SourceId("test".to_string()),
488        ];
489
490        for filter in filters {
491            let cycled = filter.cycle();
492            assert!(
493                !matches!(cycled, SourceFilter::SourceId(_)),
494                "Cycle should never produce SourceId variant, got {:?}",
495                cycled
496            );
497        }
498    }
499}