use serde::{Deserialize, Serialize};
pub use franken_agent_detection::{LOCAL_SOURCE_ID, Origin, SourceKind};
const SOURCE_FILTER_ALL: &str = "all";
const SOURCE_FILTER_LOCAL: &str = "local";
const SOURCE_FILTER_REMOTE: &str = "remote";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Source {
pub id: String,
pub kind: SourceKind,
pub host_label: Option<String>,
pub machine_id: Option<String>,
pub platform: Option<String>,
pub config_json: Option<serde_json::Value>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
impl Source {
pub fn local() -> Self {
Self {
id: LOCAL_SOURCE_ID.to_string(),
kind: SourceKind::Local,
host_label: None,
machine_id: None,
platform: None,
config_json: None,
created_at: None,
updated_at: None,
}
}
pub fn remote(id: impl Into<String>, host_label: impl Into<String>) -> Self {
Self {
id: id.into(),
kind: SourceKind::Ssh,
host_label: Some(host_label.into()),
machine_id: None,
platform: None,
config_json: None,
created_at: None,
updated_at: None,
}
}
pub fn is_remote(&self) -> bool {
self.kind.is_remote()
}
pub fn is_local(&self) -> bool {
self.id == LOCAL_SOURCE_ID && self.kind == SourceKind::Local
}
pub fn display_label(&self) -> &str {
self.host_label.as_deref().unwrap_or(&self.id)
}
}
impl Default for Source {
fn default() -> Self {
Self::local()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceFilter {
#[default]
All,
Local,
Remote,
SourceId(String),
}
impl SourceFilter {
pub fn parse(s: &str) -> Self {
let trimmed = s.trim();
match trimmed.to_ascii_lowercase().as_str() {
"" | SOURCE_FILTER_ALL | "*" => Self::All,
SOURCE_FILTER_LOCAL => Self::Local,
SOURCE_FILTER_REMOTE => Self::Remote,
_ => Self::SourceId(trimmed.to_string()),
}
}
pub fn matches(&self, origin: &Origin) -> bool {
match self {
Self::All => true,
Self::Local => origin.is_local(),
Self::Remote => origin.is_remote(),
Self::SourceId(id) => {
let filter_id = id.trim();
!filter_id.is_empty() && origin.source_id.trim() == filter_id
}
}
}
pub fn is_all(&self) -> bool {
matches!(self, Self::All)
}
pub fn cycle(&self) -> Self {
match self {
Self::All => Self::Local,
Self::Local => Self::Remote,
Self::Remote => Self::All,
Self::SourceId(_) => Self::All,
}
}
}
impl std::fmt::Display for SourceFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::All => f.write_str(SOURCE_FILTER_ALL),
Self::Local => f.write_str(SOURCE_FILTER_LOCAL),
Self::Remote => f.write_str(SOURCE_FILTER_REMOTE),
Self::SourceId(id) => f.write_str(id),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_kind_default() {
assert_eq!(SourceKind::default(), SourceKind::Local);
}
#[test]
fn test_source_kind_is_remote() {
assert!(!SourceKind::Local.is_remote());
assert!(SourceKind::Ssh.is_remote());
}
#[test]
fn test_source_kind_display() {
assert_eq!(SourceKind::Local.to_string(), "local");
assert_eq!(SourceKind::Ssh.to_string(), "ssh");
}
#[test]
fn test_source_kind_parse() {
assert_eq!(SourceKind::parse("local"), Some(SourceKind::Local));
assert_eq!(SourceKind::parse("LOCAL"), Some(SourceKind::Local));
assert_eq!(SourceKind::parse("ssh"), Some(SourceKind::Ssh));
assert_eq!(SourceKind::parse("SSH"), Some(SourceKind::Ssh));
assert_eq!(SourceKind::parse("unknown"), None);
}
#[test]
fn test_source_kind_serialization() {
assert_eq!(
serde_json::to_string(&SourceKind::Local).unwrap(),
"\"local\""
);
assert_eq!(serde_json::to_string(&SourceKind::Ssh).unwrap(), "\"ssh\"");
}
#[test]
fn test_source_kind_deserialization() {
assert_eq!(
serde_json::from_str::<SourceKind>("\"local\"").unwrap(),
SourceKind::Local
);
assert_eq!(
serde_json::from_str::<SourceKind>("\"ssh\"").unwrap(),
SourceKind::Ssh
);
}
#[test]
fn test_source_local() {
let source = Source::local();
assert_eq!(source.id, LOCAL_SOURCE_ID);
assert_eq!(source.kind, SourceKind::Local);
assert!(source.is_local());
assert!(!source.is_remote());
}
#[test]
fn test_source_remote() {
let source = Source::remote("laptop", "user@laptop.local");
assert_eq!(source.id, "laptop");
assert_eq!(source.kind, SourceKind::Ssh);
assert_eq!(source.host_label, Some("user@laptop.local".to_string()));
assert!(source.is_remote());
assert!(!source.is_local());
}
#[test]
fn test_source_display_label() {
let local = Source::local();
assert_eq!(local.display_label(), "local");
let remote = Source::remote("laptop", "user@laptop.local");
assert_eq!(remote.display_label(), "user@laptop.local");
}
#[test]
fn test_source_default() {
let source = Source::default();
assert!(source.is_local());
}
#[test]
fn test_origin_local() {
let origin = Origin::local();
assert_eq!(origin.source_id, LOCAL_SOURCE_ID);
assert_eq!(origin.kind, SourceKind::Local);
assert!(origin.is_local());
assert!(!origin.is_remote());
}
#[test]
fn test_origin_remote() {
let origin = Origin::remote("laptop");
assert_eq!(origin.source_id, "laptop");
assert_eq!(origin.kind, SourceKind::Ssh);
assert!(origin.is_remote());
assert!(!origin.is_local());
}
#[test]
fn test_origin_remote_with_host() {
let origin = Origin::remote_with_host("laptop", "user@laptop.local");
assert_eq!(origin.source_id, "laptop");
assert_eq!(origin.host, Some("user@laptop.local".to_string()));
}
#[test]
fn test_origin_display_label() {
let local = Origin::local();
assert_eq!(local.display_label(), "local");
let remote = Origin::remote("laptop");
assert_eq!(remote.display_label(), "laptop (remote)");
let remote_with_host = Origin::remote_with_host("laptop", "user@laptop.local");
assert_eq!(
remote_with_host.display_label(),
"user@laptop.local (remote)"
);
}
#[test]
fn test_origin_short_label() {
let local = Origin::local();
assert_eq!(local.short_label(), "local");
let remote = Origin::remote_with_host("laptop", "user@laptop.local");
assert_eq!(remote.short_label(), "user@laptop.local");
}
#[test]
fn test_origin_default() {
let origin = Origin::default();
assert!(origin.is_local());
}
#[test]
fn test_origin_equality() {
let a = Origin::local();
let b = Origin::local();
assert_eq!(a, b);
let c = Origin::remote("laptop");
let d = Origin::remote("laptop");
assert_eq!(c, d);
assert_ne!(a, c);
}
#[test]
fn test_origin_serialization_roundtrip() {
let original = Origin::remote_with_host("laptop", "user@host");
let json = serde_json::to_string(&original).unwrap();
let deserialized: Origin = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_source_filter_parse() {
assert_eq!(SourceFilter::parse(SOURCE_FILTER_ALL), SourceFilter::All);
assert_eq!(SourceFilter::parse("ALL"), SourceFilter::All);
assert_eq!(SourceFilter::parse("*"), SourceFilter::All);
assert_eq!(
SourceFilter::parse(SOURCE_FILTER_LOCAL),
SourceFilter::Local
);
assert_eq!(SourceFilter::parse("LOCAL"), SourceFilter::Local);
assert_eq!(
SourceFilter::parse(SOURCE_FILTER_REMOTE),
SourceFilter::Remote
);
assert_eq!(SourceFilter::parse("REMOTE"), SourceFilter::Remote);
assert_eq!(
SourceFilter::parse("laptop"),
SourceFilter::SourceId("laptop".to_string())
);
}
#[test]
fn test_source_filter_parse_trims_whitespace() {
assert_eq!(SourceFilter::parse(" local "), SourceFilter::Local);
assert_eq!(SourceFilter::parse(" REMOTE "), SourceFilter::Remote);
assert_eq!(
SourceFilter::parse(" laptop-01 "),
SourceFilter::SourceId("laptop-01".to_string())
);
assert_eq!(SourceFilter::parse(" "), SourceFilter::All);
}
#[test]
fn test_source_filter_matches() {
let local = Origin::local();
let remote = Origin::remote("laptop");
let mut whitespace_remote = Origin::remote("laptop");
whitespace_remote.source_id = " laptop ".to_string();
assert!(SourceFilter::All.matches(&local));
assert!(SourceFilter::All.matches(&remote));
assert!(SourceFilter::Local.matches(&local));
assert!(!SourceFilter::Local.matches(&remote));
assert!(!SourceFilter::Remote.matches(&local));
assert!(SourceFilter::Remote.matches(&remote));
assert!(SourceFilter::SourceId("laptop".to_string()).matches(&remote));
assert!(SourceFilter::SourceId(" laptop ".to_string()).matches(&whitespace_remote));
assert!(!SourceFilter::SourceId("laptop".to_string()).matches(&local));
assert!(!SourceFilter::SourceId("other".to_string()).matches(&remote));
assert!(!SourceFilter::SourceId(" ".to_string()).matches(&remote));
}
#[test]
fn test_source_filter_display() {
assert_eq!(SourceFilter::All.to_string(), SOURCE_FILTER_ALL);
assert_eq!(SourceFilter::Local.to_string(), SOURCE_FILTER_LOCAL);
assert_eq!(SourceFilter::Remote.to_string(), SOURCE_FILTER_REMOTE);
assert_eq!(
SourceFilter::SourceId("laptop".to_string()).to_string(),
"laptop"
);
}
#[test]
fn test_source_filter_default() {
assert_eq!(SourceFilter::default(), SourceFilter::All);
}
#[test]
fn test_source_filter_cycle_transitions() {
for (case, filter, expected) in [
("all to local", SourceFilter::All, SourceFilter::Local),
("local to remote", SourceFilter::Local, SourceFilter::Remote),
("remote to all", SourceFilter::Remote, SourceFilter::All),
(
"specific to all",
SourceFilter::SourceId("laptop".to_string()),
SourceFilter::All,
),
] {
assert_eq!(filter.cycle(), expected, "{case}");
}
}
#[test]
fn test_source_filter_full_cycle() {
let filter = SourceFilter::All;
let after_one = filter.cycle();
let after_two = after_one.cycle();
let after_three = after_two.cycle();
assert_eq!(after_one, SourceFilter::Local);
assert_eq!(after_two, SourceFilter::Remote);
assert_eq!(after_three, SourceFilter::All);
}
#[test]
fn test_source_filter_cycle_is_idempotent_for_specific() {
let filter = SourceFilter::SourceId("work-laptop".to_string());
let cycled = filter.cycle();
assert_eq!(cycled, SourceFilter::All);
assert_eq!(cycled.cycle(), SourceFilter::Local);
}
#[test]
fn test_source_filter_cycle_preserves_type_invariants() {
let filters = [
SourceFilter::All,
SourceFilter::Local,
SourceFilter::Remote,
SourceFilter::SourceId("test".to_string()),
];
for filter in filters {
let cycled = filter.cycle();
assert!(
!matches!(cycled, SourceFilter::SourceId(_)),
"Cycle should never produce SourceId variant, got {:?}",
cycled
);
}
}
}