coding_agent_search/sources/
provenance.rs1use serde::{Deserialize, Serialize};
27
28pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Source {
49 pub id: String,
52
53 pub kind: SourceKind,
55
56 pub host_label: Option<String>,
59
60 pub machine_id: Option<String>,
62
63 pub platform: Option<String>,
65
66 pub config_json: Option<serde_json::Value>,
68
69 pub created_at: Option<i64>,
71
72 pub updated_at: Option<i64>,
74}
75
76impl Source {
77 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 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 pub fn is_remote(&self) -> bool {
107 self.kind.is_remote()
108 }
109
110 pub fn is_local(&self) -> bool {
112 self.id == LOCAL_SOURCE_ID && self.kind == SourceKind::Local
113 }
114
115 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#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
131#[serde(rename_all = "snake_case")]
132pub enum SourceFilter {
133 #[default]
135 All,
136 Local,
138 Remote,
140 SourceId(String),
142}
143
144impl SourceFilter {
145 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 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 pub fn is_all(&self) -> bool {
176 matches!(self, Self::All)
177 }
178
179 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 #[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 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 let filter = SourceFilter::SourceId("work-laptop".to_string());
473 let cycled = filter.cycle();
474 assert_eq!(cycled, SourceFilter::All);
475
476 assert_eq!(cycled.cycle(), SourceFilter::Local);
478 }
479
480 #[test]
481 fn test_source_filter_cycle_preserves_type_invariants() {
482 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}