1use crate::Result;
7use crate::error::DaemonIdError;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt::{self, Display};
10use std::hash::Hash;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct DaemonId {
35 namespace: String,
36 name: String,
37}
38
39impl Default for DaemonId {
40 fn default() -> Self {
41 Self {
42 namespace: "global".to_string(),
43 name: "unknown".to_string(),
44 }
45 }
46}
47
48impl DaemonId {
49 #[cfg(test)]
64 pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
65 let namespace = namespace.into();
66 let name = name.into();
67
68 if let Err(e) = validate_component(&namespace, "namespace") {
70 panic!("Invalid namespace '{namespace}': {e}");
71 }
72 if let Err(e) = validate_component(&name, "name") {
73 panic!("Invalid name '{name}': {e}");
74 }
75
76 Self { namespace, name }
77 }
78
79 pub(crate) fn new_unchecked(namespace: impl Into<String>, name: impl Into<String>) -> Self {
89 Self {
90 namespace: namespace.into(),
91 name: name.into(),
92 }
93 }
94
95 pub fn try_new(namespace: impl Into<String>, name: impl Into<String>) -> Result<Self> {
99 let namespace = namespace.into();
100 let name = name.into();
101
102 validate_component(&namespace, "namespace")?;
103 validate_component(&name, "name")?;
104
105 Ok(Self { namespace, name })
106 }
107
108 pub fn parse(s: &str) -> Result<Self> {
122 validate_qualified_id(s)?;
123
124 let (ns, name) = s
126 .split_once('/')
127 .expect("validate_qualified_id ensures '/' is present");
128 Ok(Self {
129 namespace: ns.to_string(),
130 name: name.to_string(),
131 })
132 }
133
134 pub fn from_safe_path(s: &str) -> Result<Self> {
157 if let Some((ns, name)) = s.split_once("--") {
158 validate_component(ns, "namespace")?;
162 validate_component(name, "name")?;
163 Ok(Self {
164 namespace: ns.to_string(),
165 name: name.to_string(),
166 })
167 } else {
168 Err(DaemonIdError::InvalidSafePath {
169 path: s.to_string(),
170 }
171 .into())
172 }
173 }
174
175 pub fn namespace(&self) -> &str {
177 &self.namespace
178 }
179
180 pub fn pitchfork() -> Self {
184 Self::new_unchecked("global", "pitchfork")
186 }
187
188 pub fn name(&self) -> &str {
190 &self.name
191 }
192
193 pub fn qualified(&self) -> String {
195 format!("{}/{}", self.namespace, self.name)
196 }
197
198 pub fn safe_path(&self) -> String {
200 format!("{}--{}", self.namespace, self.name)
201 }
202
203 pub fn log_path(&self) -> std::path::PathBuf {
205 let safe = self.safe_path();
206 crate::env::PITCHFORK_LOGS_DIR
207 .join(&safe)
208 .join(format!("{safe}.log"))
209 }
210
211 pub fn styled_qualified(&self) -> String {
215 use crate::ui::style::ndim;
216 format!("{}/{}", ndim(&self.namespace), self.name)
217 }
218}
219
220impl Display for DaemonId {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(f, "{}/{}", self.namespace, self.name)
223 }
224}
225
226impl Serialize for DaemonId {
234 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
235 where
236 S: Serializer,
237 {
238 serializer.serialize_str(&self.qualified())
239 }
240}
241
242impl<'de> Deserialize<'de> for DaemonId {
244 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
245 where
246 D: Deserializer<'de>,
247 {
248 let s = String::deserialize(deserializer)?;
249 DaemonId::parse(&s).map_err(serde::de::Error::custom)
250 }
251}
252
253impl schemars::JsonSchema for DaemonId {
260 fn schema_name() -> std::borrow::Cow<'static, str> {
261 "DaemonId".into()
262 }
263
264 fn schema_id() -> std::borrow::Cow<'static, str> {
265 concat!(module_path!(), "::DaemonId").into()
266 }
267
268 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
269 schemars::json_schema!({
270 "type": "string",
271 "description": "Daemon name (e.g. 'api') or qualified ID ('namespace/name') for cross-namespace references",
272 "pattern": r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$",
273 "not": {
274 "pattern": r"\.\.|--|(^|/)-|-($|/)|^\.$|^\./|/\.$"
275 }
276 })
277 }
278}
279
280pub fn validate_namespace(namespace: &str) -> Result<()> {
285 validate_component(namespace, "namespace")
286}
287
288fn validate_component(s: &str, component_name: &str) -> Result<()> {
290 if s.is_empty() {
291 return Err(DaemonIdError::EmptyComponent {
292 component: component_name.to_string(),
293 }
294 .into());
295 }
296 if s.contains('/') {
297 return Err(DaemonIdError::PathSeparator {
298 id: s.to_string(),
299 sep: '/',
300 }
301 .into());
302 }
303 if s.contains('\\') {
304 return Err(DaemonIdError::PathSeparator {
305 id: s.to_string(),
306 sep: '\\',
307 }
308 .into());
309 }
310 if s.contains("..") {
311 return Err(DaemonIdError::ParentDirRef { id: s.to_string() }.into());
312 }
313 if s.contains("--") {
314 return Err(DaemonIdError::ReservedSequence { id: s.to_string() }.into());
315 }
316 if s.starts_with('-') || s.ends_with('-') {
317 return Err(DaemonIdError::LeadingTrailingDash { id: s.to_string() }.into());
318 }
319 if s.contains(' ') {
320 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
321 }
322 if s == "." {
323 return Err(DaemonIdError::CurrentDir.into());
324 }
325 if !s
326 .chars()
327 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
328 {
329 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
330 }
331 Ok(())
332}
333
334fn validate_qualified_id(s: &str) -> Result<()> {
336 if s.is_empty() {
337 return Err(DaemonIdError::Empty.into());
338 }
339 if s.contains('\\') {
340 return Err(DaemonIdError::PathSeparator {
341 id: s.to_string(),
342 sep: '\\',
343 }
344 .into());
345 }
346 if s.contains(' ') {
347 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
348 }
349 if !s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
350 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
351 }
352
353 let slash_count = s.chars().filter(|&c| c == '/').count();
355 if slash_count == 0 {
356 return Err(DaemonIdError::MissingNamespace { id: s.to_string() }.into());
357 }
358 if slash_count > 1 {
359 return Err(DaemonIdError::PathSeparator {
360 id: s.to_string(),
361 sep: '/',
362 }
363 .into());
364 }
365
366 let (ns, name) = s.split_once('/').unwrap();
368 if ns.is_empty() || name.is_empty() {
369 return Err(DaemonIdError::PathSeparator {
370 id: s.to_string(),
371 sep: '/',
372 }
373 .into());
374 }
375
376 validate_component(ns, "namespace")?;
379 validate_component(name, "name")?;
380
381 Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_daemon_id_new() {
390 let id = DaemonId::new("global", "api");
391 assert_eq!(id.namespace(), "global");
392 assert_eq!(id.name(), "api");
393 assert_eq!(id.qualified(), "global/api");
394 assert_eq!(id.safe_path(), "global--api");
395 }
396
397 #[test]
398 fn test_daemon_id_parse() {
399 let id = DaemonId::parse("project-a/api").unwrap();
400 assert_eq!(id.namespace(), "project-a");
401 assert_eq!(id.name(), "api");
402
403 assert!(DaemonId::parse("api").is_err());
405
406 assert!(DaemonId::parse("/api").is_err());
408 assert!(DaemonId::parse("project/").is_err());
409
410 assert!(DaemonId::parse("a/b/c").is_err());
412 }
413
414 #[test]
415 fn test_daemon_id_from_safe_path() {
416 let id = DaemonId::from_safe_path("project-a--api").unwrap();
417 assert_eq!(id.namespace(), "project-a");
418 assert_eq!(id.name(), "api");
419
420 assert!(DaemonId::from_safe_path("projectapi").is_err());
422 }
423
424 #[test]
425 fn test_daemon_id_roundtrip() {
426 let original = DaemonId::new("my-project", "my-daemon");
427 let safe = original.safe_path();
428 let recovered = DaemonId::from_safe_path(&safe).unwrap();
429 assert_eq!(original, recovered);
430 }
431
432 #[test]
433 fn test_daemon_id_display() {
434 let id = DaemonId::new("global", "api");
435 assert_eq!(format!("{id}"), "global/api");
436 }
437
438 #[test]
439 fn test_daemon_id_serialize() {
440 let id = DaemonId::new("global", "api");
441 let json = serde_json::to_string(&id).unwrap();
442 assert_eq!(json, "\"global/api\"");
443
444 let deserialized: DaemonId = serde_json::from_str(&json).unwrap();
445 assert_eq!(id, deserialized);
446 }
447
448 #[test]
449 fn test_daemon_id_validation() {
450 assert!(DaemonId::try_new("global", "api").is_ok());
452 assert!(DaemonId::try_new("my-project", "my-daemon").is_ok());
453 assert!(DaemonId::try_new("project_a", "daemon_1").is_ok());
454
455 assert!(DaemonId::try_new("my--project", "api").is_err());
457 assert!(DaemonId::try_new("project", "my--daemon").is_err());
458
459 assert!(DaemonId::try_new("my/project", "api").is_err());
461 assert!(DaemonId::try_new("project", "my/daemon").is_err());
462
463 assert!(DaemonId::try_new("", "api").is_err());
465 assert!(DaemonId::try_new("project", "").is_err());
466 }
467
468 #[test]
469 fn test_daemon_id_ordering() {
470 let id1 = DaemonId::new("a", "x");
471 let id2 = DaemonId::new("a", "y");
472 let id3 = DaemonId::new("b", "x");
473
474 assert!(id1 < id2);
475 assert!(id2 < id3);
476 assert!(id1 < id3);
477 }
478
479 #[test]
481 fn test_from_safe_path_double_dash_in_namespace_rejected() {
482 assert!(DaemonId::from_safe_path("my--project--api").is_err());
486 assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
487 }
488
489 #[test]
490 fn test_from_safe_path_roundtrip_via_qualified() {
491 let id = DaemonId::from_safe_path("global--api").unwrap();
493 assert_eq!(id.namespace(), "global");
494 assert_eq!(id.name(), "api");
495 let recovered = DaemonId::parse(&id.qualified()).unwrap();
497 assert_eq!(recovered, id);
498 }
499
500 #[test]
501 fn test_from_safe_path_no_separator() {
502 assert!(DaemonId::from_safe_path("globalapi").is_err());
504 assert!(DaemonId::from_safe_path("api").is_err());
505 }
506
507 #[test]
508 fn test_from_safe_path_empty_parts() {
509 let result = DaemonId::from_safe_path("--api");
511 assert!(result.is_err());
512
513 let result = DaemonId::from_safe_path("namespace--");
515 assert!(result.is_err());
516 }
517
518 #[test]
520 fn test_parse_cross_namespace_dependency() {
521 let id = DaemonId::parse("other-project/postgres").unwrap();
523 assert_eq!(id.namespace(), "other-project");
524 assert_eq!(id.name(), "postgres");
525 }
526
527 #[test]
529 fn test_directory_with_double_dash_in_name() {
530 let result = DaemonId::try_new("my--project", "api");
532 assert!(result.is_err());
533
534 let result = DaemonId::from_safe_path("my--project--api");
538 assert!(
539 result.is_err(),
540 "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
541 );
542 }
543
544 #[test]
545 fn test_parse_dot_namespace_rejected() {
546 let result = DaemonId::parse("./api");
549 assert!(result.is_err());
550
551 let result = DaemonId::parse("../api");
553 assert!(result.is_err());
554 }
555
556 #[test]
558 fn test_daemon_id_toml_roundtrip() {
559 #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
560 struct TestConfig {
561 daemon_id: DaemonId,
562 }
563
564 let config = TestConfig {
565 daemon_id: DaemonId::new("my-project", "api"),
566 };
567
568 let toml_str = toml::to_string(&config).unwrap();
569 assert!(toml_str.contains("daemon_id = \"my-project/api\""));
570
571 let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
572 assert_eq!(config, recovered);
573 }
574
575 #[test]
576 fn test_daemon_id_json_roundtrip_in_map() {
577 use std::collections::HashMap;
578
579 let mut map: HashMap<String, DaemonId> = HashMap::new();
580 map.insert("primary".to_string(), DaemonId::new("global", "api"));
581 map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
582
583 let json = serde_json::to_string(&map).unwrap();
584 let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
585 assert_eq!(map, recovered);
586 }
587
588 #[test]
590 fn test_pitchfork_id() {
591 let id = DaemonId::pitchfork();
592 assert_eq!(id.namespace(), "global");
593 assert_eq!(id.name(), "pitchfork");
594 assert_eq!(id.qualified(), "global/pitchfork");
595 }
596
597 #[test]
599 fn test_daemon_id_rejects_unicode() {
600 assert!(DaemonId::try_new("プロジェクト", "api").is_err());
601 assert!(DaemonId::try_new("project", "工作者").is_err());
602 }
603
604 #[test]
605 fn test_daemon_id_rejects_control_chars() {
606 assert!(DaemonId::try_new("project\x00", "api").is_err());
607 assert!(DaemonId::try_new("project", "api\x1b").is_err());
608 }
609
610 #[test]
611 fn test_daemon_id_rejects_spaces() {
612 assert!(DaemonId::try_new("my project", "api").is_err());
613 assert!(DaemonId::try_new("project", "my api").is_err());
614 assert!(DaemonId::parse("my project/api").is_err());
615 }
616
617 #[test]
618 fn test_daemon_id_rejects_chars_outside_schema_pattern() {
619 assert!(DaemonId::try_new("project+alpha", "api").is_err());
621 assert!(DaemonId::try_new("project", "api@v1").is_err());
622 }
623
624 #[test]
625 fn test_daemon_id_rejects_leading_trailing_dash() {
626 assert!(DaemonId::try_new("-project", "api").is_err());
628 assert!(DaemonId::try_new("project", "-api").is_err());
629 assert!(DaemonId::try_new("project-", "api").is_err());
631 assert!(DaemonId::try_new("project", "api-").is_err());
632 let id = DaemonId::try_new("a", "b").unwrap();
634 let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
635 assert_eq!(id, recovered);
636 assert!(DaemonId::from_safe_path("a---b").is_err()); }
639
640 #[test]
641 fn test_daemon_id_rejects_parent_dir_traversal() {
642 assert!(DaemonId::try_new("project", "..").is_err());
643 assert!(DaemonId::try_new("..", "api").is_err());
644 assert!(DaemonId::parse("../api").is_err());
645 assert!(DaemonId::parse("project/..").is_err());
646 }
647
648 #[test]
649 fn test_daemon_id_rejects_current_dir() {
650 assert!(DaemonId::try_new(".", "api").is_err());
651 assert!(DaemonId::try_new("project", ".").is_err());
652 }
653
654 #[test]
655 fn test_validate_namespace() {
656 assert!(validate_namespace("frontend").is_ok());
657 assert!(validate_namespace("global").is_ok());
658 assert!(validate_namespace("my-project").is_ok());
659 assert!(validate_namespace("").is_err());
661 assert!(validate_namespace("my--ns").is_err());
662 assert!(validate_namespace("a/b").is_err());
663 assert!(validate_namespace("has space").is_err());
664 assert!(validate_namespace("-lead").is_err());
665 }
666
667 #[test]
669 fn test_daemon_id_hash_consistency() {
670 use std::collections::HashSet;
671
672 let id1 = DaemonId::new("project", "api");
673 let id2 = DaemonId::new("project", "api");
674 let id3 = DaemonId::parse("project/api").unwrap();
675
676 let mut set = HashSet::new();
677 set.insert(id1.clone());
678
679 assert!(set.contains(&id2));
681 assert!(set.contains(&id3));
682
683 assert_eq!(id1, id2);
685 assert_eq!(id2, id3);
686 }
687}