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 DaemonId {
40 #[cfg(test)]
55 pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
56 let namespace = namespace.into();
57 let name = name.into();
58
59 if let Err(e) = validate_component(&namespace, "namespace") {
61 panic!("Invalid namespace '{}': {}", namespace, e);
62 }
63 if let Err(e) = validate_component(&name, "name") {
64 panic!("Invalid name '{}': {}", name, e);
65 }
66
67 Self { namespace, name }
68 }
69
70 pub(crate) fn new_unchecked(namespace: impl Into<String>, name: impl Into<String>) -> Self {
80 Self {
81 namespace: namespace.into(),
82 name: name.into(),
83 }
84 }
85
86 pub fn try_new(namespace: impl Into<String>, name: impl Into<String>) -> Result<Self> {
90 let namespace = namespace.into();
91 let name = name.into();
92
93 validate_component(&namespace, "namespace")?;
94 validate_component(&name, "name")?;
95
96 Ok(Self { namespace, name })
97 }
98
99 pub fn parse(s: &str) -> Result<Self> {
113 validate_qualified_id(s)?;
114
115 let (ns, name) = s
117 .split_once('/')
118 .expect("validate_qualified_id ensures '/' is present");
119 Ok(Self {
120 namespace: ns.to_string(),
121 name: name.to_string(),
122 })
123 }
124
125 pub fn from_safe_path(s: &str) -> Result<Self> {
148 if let Some((ns, name)) = s.split_once("--") {
149 validate_component(ns, "namespace")?;
153 validate_component(name, "name")?;
154 Ok(Self {
155 namespace: ns.to_string(),
156 name: name.to_string(),
157 })
158 } else {
159 Err(DaemonIdError::InvalidSafePath {
160 path: s.to_string(),
161 }
162 .into())
163 }
164 }
165
166 pub fn namespace(&self) -> &str {
168 &self.namespace
169 }
170
171 pub fn pitchfork() -> Self {
175 Self::new_unchecked("global", "pitchfork")
177 }
178
179 pub fn name(&self) -> &str {
181 &self.name
182 }
183
184 pub fn qualified(&self) -> String {
186 format!("{}/{}", self.namespace, self.name)
187 }
188
189 pub fn safe_path(&self) -> String {
191 format!("{}--{}", self.namespace, self.name)
192 }
193
194 pub fn log_path(&self) -> std::path::PathBuf {
196 let safe = self.safe_path();
197 crate::env::PITCHFORK_LOGS_DIR
198 .join(&safe)
199 .join(format!("{safe}.log"))
200 }
201
202 pub fn styled_display_name<'a, I>(&self, all_ids: Option<I>) -> String
207 where
208 I: Iterator<Item = &'a DaemonId>,
209 {
210 let show_full = match all_ids {
211 Some(ids) => ids.filter(|other| other.name == self.name).count() > 1,
212 None => true,
213 };
214
215 if show_full {
216 self.styled_qualified()
217 } else {
218 self.name.clone()
219 }
220 }
221
222 pub fn styled_qualified(&self) -> String {
226 use crate::ui::style::ndim;
227 format!("{}/{}", ndim(&self.namespace), self.name)
228 }
229}
230
231impl Display for DaemonId {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 write!(f, "{}/{}", self.namespace, self.name)
234 }
235}
236
237impl Serialize for DaemonId {
245 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
246 where
247 S: Serializer,
248 {
249 serializer.serialize_str(&self.qualified())
250 }
251}
252
253impl<'de> Deserialize<'de> for DaemonId {
255 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
256 where
257 D: Deserializer<'de>,
258 {
259 let s = String::deserialize(deserializer)?;
260 DaemonId::parse(&s).map_err(serde::de::Error::custom)
261 }
262}
263
264impl schemars::JsonSchema for DaemonId {
271 fn schema_name() -> std::borrow::Cow<'static, str> {
272 "DaemonId".into()
273 }
274
275 fn schema_id() -> std::borrow::Cow<'static, str> {
276 concat!(module_path!(), "::DaemonId").into()
277 }
278
279 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
280 schemars::json_schema!({
281 "type": "string",
282 "description": "Daemon name (e.g. 'api') or qualified ID ('namespace/name') for cross-namespace references",
283 "pattern": r"^[\w.-]+(/[\w.-]+)?$"
284 })
285 }
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_styled_display_name() {
470 let id1 = DaemonId::new("project-a", "api");
471 let id2 = DaemonId::new("project-b", "api");
472 let id3 = DaemonId::new("global", "worker");
473
474 let all_ids = [&id1, &id2, &id3];
475
476 let out1 = id1.styled_display_name(Some(all_ids.iter().copied()));
478 let out2 = id2.styled_display_name(Some(all_ids.iter().copied()));
479 assert!(
480 out1.contains("project-a") && out1.contains("api"),
481 "ambiguous id1 should show namespace: {out1}"
482 );
483 assert!(
484 out2.contains("project-b") && out2.contains("api"),
485 "ambiguous id2 should show namespace: {out2}"
486 );
487
488 let out3 = id3.styled_display_name(Some(all_ids.iter().copied()));
490 assert_eq!(out3, "worker", "unique id3 should show only short name");
491 }
492
493 #[test]
494 fn test_daemon_id_ordering() {
495 let id1 = DaemonId::new("a", "x");
496 let id2 = DaemonId::new("a", "y");
497 let id3 = DaemonId::new("b", "x");
498
499 assert!(id1 < id2);
500 assert!(id2 < id3);
501 assert!(id1 < id3);
502 }
503
504 #[test]
506 fn test_from_safe_path_double_dash_in_namespace_rejected() {
507 assert!(DaemonId::from_safe_path("my--project--api").is_err());
511 assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
512 }
513
514 #[test]
515 fn test_from_safe_path_roundtrip_via_qualified() {
516 let id = DaemonId::from_safe_path("global--api").unwrap();
518 assert_eq!(id.namespace(), "global");
519 assert_eq!(id.name(), "api");
520 let recovered = DaemonId::parse(&id.qualified()).unwrap();
522 assert_eq!(recovered, id);
523 }
524
525 #[test]
526 fn test_from_safe_path_no_separator() {
527 assert!(DaemonId::from_safe_path("globalapi").is_err());
529 assert!(DaemonId::from_safe_path("api").is_err());
530 }
531
532 #[test]
533 fn test_from_safe_path_empty_parts() {
534 let result = DaemonId::from_safe_path("--api");
536 assert!(result.is_err());
537
538 let result = DaemonId::from_safe_path("namespace--");
540 assert!(result.is_err());
541 }
542
543 #[test]
545 fn test_parse_cross_namespace_dependency() {
546 let id = DaemonId::parse("other-project/postgres").unwrap();
548 assert_eq!(id.namespace(), "other-project");
549 assert_eq!(id.name(), "postgres");
550 }
551
552 #[test]
554 fn test_directory_with_double_dash_in_name() {
555 let result = DaemonId::try_new("my--project", "api");
557 assert!(result.is_err());
558
559 let result = DaemonId::from_safe_path("my--project--api");
563 assert!(
564 result.is_err(),
565 "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
566 );
567 }
568
569 #[test]
570 fn test_parse_dot_namespace_rejected() {
571 let result = DaemonId::parse("./api");
574 assert!(result.is_err());
575
576 let result = DaemonId::parse("../api");
578 assert!(result.is_err());
579 }
580
581 #[test]
583 fn test_daemon_id_toml_roundtrip() {
584 #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
585 struct TestConfig {
586 daemon_id: DaemonId,
587 }
588
589 let config = TestConfig {
590 daemon_id: DaemonId::new("my-project", "api"),
591 };
592
593 let toml_str = toml::to_string(&config).unwrap();
594 assert!(toml_str.contains("daemon_id = \"my-project/api\""));
595
596 let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
597 assert_eq!(config, recovered);
598 }
599
600 #[test]
601 fn test_daemon_id_json_roundtrip_in_map() {
602 use std::collections::HashMap;
603
604 let mut map: HashMap<String, DaemonId> = HashMap::new();
605 map.insert("primary".to_string(), DaemonId::new("global", "api"));
606 map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
607
608 let json = serde_json::to_string(&map).unwrap();
609 let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
610 assert_eq!(map, recovered);
611 }
612
613 #[test]
615 fn test_pitchfork_id() {
616 let id = DaemonId::pitchfork();
617 assert_eq!(id.namespace(), "global");
618 assert_eq!(id.name(), "pitchfork");
619 assert_eq!(id.qualified(), "global/pitchfork");
620 }
621
622 #[test]
624 fn test_daemon_id_rejects_unicode() {
625 assert!(DaemonId::try_new("プロジェクト", "api").is_err());
626 assert!(DaemonId::try_new("project", "工作者").is_err());
627 }
628
629 #[test]
630 fn test_daemon_id_rejects_control_chars() {
631 assert!(DaemonId::try_new("project\x00", "api").is_err());
632 assert!(DaemonId::try_new("project", "api\x1b").is_err());
633 }
634
635 #[test]
636 fn test_daemon_id_rejects_spaces() {
637 assert!(DaemonId::try_new("my project", "api").is_err());
638 assert!(DaemonId::try_new("project", "my api").is_err());
639 assert!(DaemonId::parse("my project/api").is_err());
640 }
641
642 #[test]
643 fn test_daemon_id_rejects_chars_outside_schema_pattern() {
644 assert!(DaemonId::try_new("project+alpha", "api").is_err());
646 assert!(DaemonId::try_new("project", "api@v1").is_err());
647 }
648
649 #[test]
650 fn test_daemon_id_rejects_leading_trailing_dash() {
651 assert!(DaemonId::try_new("-project", "api").is_err());
653 assert!(DaemonId::try_new("project", "-api").is_err());
654 assert!(DaemonId::try_new("project-", "api").is_err());
656 assert!(DaemonId::try_new("project", "api-").is_err());
657 let id = DaemonId::try_new("a", "b").unwrap();
659 let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
660 assert_eq!(id, recovered);
661 assert!(DaemonId::from_safe_path("a---b").is_err()); }
664
665 #[test]
666 fn test_daemon_id_rejects_parent_dir_traversal() {
667 assert!(DaemonId::try_new("project", "..").is_err());
668 assert!(DaemonId::try_new("..", "api").is_err());
669 assert!(DaemonId::parse("../api").is_err());
670 assert!(DaemonId::parse("project/..").is_err());
671 }
672
673 #[test]
674 fn test_daemon_id_rejects_current_dir() {
675 assert!(DaemonId::try_new(".", "api").is_err());
676 assert!(DaemonId::try_new("project", ".").is_err());
677 }
678
679 #[test]
681 fn test_daemon_id_hash_consistency() {
682 use std::collections::HashSet;
683
684 let id1 = DaemonId::new("project", "api");
685 let id2 = DaemonId::new("project", "api");
686 let id3 = DaemonId::parse("project/api").unwrap();
687
688 let mut set = HashSet::new();
689 set.insert(id1.clone());
690
691 assert!(set.contains(&id2));
693 assert!(set.contains(&id3));
694
695 assert_eq!(id1, id2);
697 assert_eq!(id2, id3);
698 }
699}