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_display_name<'a, I>(&self, all_ids: Option<I>) -> String
216 where
217 I: Iterator<Item = &'a DaemonId>,
218 {
219 let show_full = match all_ids {
220 Some(ids) => ids.filter(|other| other.name == self.name).count() > 1,
221 None => true,
222 };
223
224 if show_full {
225 self.styled_qualified()
226 } else {
227 self.name.clone()
228 }
229 }
230
231 pub fn styled_qualified(&self) -> String {
235 use crate::ui::style::ndim;
236 format!("{}/{}", ndim(&self.namespace), self.name)
237 }
238}
239
240impl Display for DaemonId {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 write!(f, "{}/{}", self.namespace, self.name)
243 }
244}
245
246impl Serialize for DaemonId {
254 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
255 where
256 S: Serializer,
257 {
258 serializer.serialize_str(&self.qualified())
259 }
260}
261
262impl<'de> Deserialize<'de> for DaemonId {
264 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
265 where
266 D: Deserializer<'de>,
267 {
268 let s = String::deserialize(deserializer)?;
269 DaemonId::parse(&s).map_err(serde::de::Error::custom)
270 }
271}
272
273impl schemars::JsonSchema for DaemonId {
280 fn schema_name() -> std::borrow::Cow<'static, str> {
281 "DaemonId".into()
282 }
283
284 fn schema_id() -> std::borrow::Cow<'static, str> {
285 concat!(module_path!(), "::DaemonId").into()
286 }
287
288 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
289 schemars::json_schema!({
290 "type": "string",
291 "description": "Daemon name (e.g. 'api') or qualified ID ('namespace/name') for cross-namespace references",
292 "pattern": r"^[\w.-]+(/[\w.-]+)?$"
293 })
294 }
295}
296
297fn validate_component(s: &str, component_name: &str) -> Result<()> {
299 if s.is_empty() {
300 return Err(DaemonIdError::EmptyComponent {
301 component: component_name.to_string(),
302 }
303 .into());
304 }
305 if s.contains('/') {
306 return Err(DaemonIdError::PathSeparator {
307 id: s.to_string(),
308 sep: '/',
309 }
310 .into());
311 }
312 if s.contains('\\') {
313 return Err(DaemonIdError::PathSeparator {
314 id: s.to_string(),
315 sep: '\\',
316 }
317 .into());
318 }
319 if s.contains("..") {
320 return Err(DaemonIdError::ParentDirRef { id: s.to_string() }.into());
321 }
322 if s.contains("--") {
323 return Err(DaemonIdError::ReservedSequence { id: s.to_string() }.into());
324 }
325 if s.starts_with('-') || s.ends_with('-') {
326 return Err(DaemonIdError::LeadingTrailingDash { id: s.to_string() }.into());
327 }
328 if s.contains(' ') {
329 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
330 }
331 if s == "." {
332 return Err(DaemonIdError::CurrentDir.into());
333 }
334 if !s
335 .chars()
336 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
337 {
338 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
339 }
340 Ok(())
341}
342
343fn validate_qualified_id(s: &str) -> Result<()> {
345 if s.is_empty() {
346 return Err(DaemonIdError::Empty.into());
347 }
348 if s.contains('\\') {
349 return Err(DaemonIdError::PathSeparator {
350 id: s.to_string(),
351 sep: '\\',
352 }
353 .into());
354 }
355 if s.contains(' ') {
356 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
357 }
358 if !s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
359 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
360 }
361
362 let slash_count = s.chars().filter(|&c| c == '/').count();
364 if slash_count == 0 {
365 return Err(DaemonIdError::MissingNamespace { id: s.to_string() }.into());
366 }
367 if slash_count > 1 {
368 return Err(DaemonIdError::PathSeparator {
369 id: s.to_string(),
370 sep: '/',
371 }
372 .into());
373 }
374
375 let (ns, name) = s.split_once('/').unwrap();
377 if ns.is_empty() || name.is_empty() {
378 return Err(DaemonIdError::PathSeparator {
379 id: s.to_string(),
380 sep: '/',
381 }
382 .into());
383 }
384
385 validate_component(ns, "namespace")?;
388 validate_component(name, "name")?;
389
390 Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_daemon_id_new() {
399 let id = DaemonId::new("global", "api");
400 assert_eq!(id.namespace(), "global");
401 assert_eq!(id.name(), "api");
402 assert_eq!(id.qualified(), "global/api");
403 assert_eq!(id.safe_path(), "global--api");
404 }
405
406 #[test]
407 fn test_daemon_id_parse() {
408 let id = DaemonId::parse("project-a/api").unwrap();
409 assert_eq!(id.namespace(), "project-a");
410 assert_eq!(id.name(), "api");
411
412 assert!(DaemonId::parse("api").is_err());
414
415 assert!(DaemonId::parse("/api").is_err());
417 assert!(DaemonId::parse("project/").is_err());
418
419 assert!(DaemonId::parse("a/b/c").is_err());
421 }
422
423 #[test]
424 fn test_daemon_id_from_safe_path() {
425 let id = DaemonId::from_safe_path("project-a--api").unwrap();
426 assert_eq!(id.namespace(), "project-a");
427 assert_eq!(id.name(), "api");
428
429 assert!(DaemonId::from_safe_path("projectapi").is_err());
431 }
432
433 #[test]
434 fn test_daemon_id_roundtrip() {
435 let original = DaemonId::new("my-project", "my-daemon");
436 let safe = original.safe_path();
437 let recovered = DaemonId::from_safe_path(&safe).unwrap();
438 assert_eq!(original, recovered);
439 }
440
441 #[test]
442 fn test_daemon_id_display() {
443 let id = DaemonId::new("global", "api");
444 assert_eq!(format!("{}", id), "global/api");
445 }
446
447 #[test]
448 fn test_daemon_id_serialize() {
449 let id = DaemonId::new("global", "api");
450 let json = serde_json::to_string(&id).unwrap();
451 assert_eq!(json, "\"global/api\"");
452
453 let deserialized: DaemonId = serde_json::from_str(&json).unwrap();
454 assert_eq!(id, deserialized);
455 }
456
457 #[test]
458 fn test_daemon_id_validation() {
459 assert!(DaemonId::try_new("global", "api").is_ok());
461 assert!(DaemonId::try_new("my-project", "my-daemon").is_ok());
462 assert!(DaemonId::try_new("project_a", "daemon_1").is_ok());
463
464 assert!(DaemonId::try_new("my--project", "api").is_err());
466 assert!(DaemonId::try_new("project", "my--daemon").is_err());
467
468 assert!(DaemonId::try_new("my/project", "api").is_err());
470 assert!(DaemonId::try_new("project", "my/daemon").is_err());
471
472 assert!(DaemonId::try_new("", "api").is_err());
474 assert!(DaemonId::try_new("project", "").is_err());
475 }
476
477 #[test]
478 fn test_daemon_id_styled_display_name() {
479 let id1 = DaemonId::new("project-a", "api");
480 let id2 = DaemonId::new("project-b", "api");
481 let id3 = DaemonId::new("global", "worker");
482
483 let all_ids = [&id1, &id2, &id3];
484
485 let out1 = id1.styled_display_name(Some(all_ids.iter().copied()));
487 let out2 = id2.styled_display_name(Some(all_ids.iter().copied()));
488 assert!(
489 out1.contains("project-a") && out1.contains("api"),
490 "ambiguous id1 should show namespace: {out1}"
491 );
492 assert!(
493 out2.contains("project-b") && out2.contains("api"),
494 "ambiguous id2 should show namespace: {out2}"
495 );
496
497 let out3 = id3.styled_display_name(Some(all_ids.iter().copied()));
499 assert_eq!(out3, "worker", "unique id3 should show only short name");
500 }
501
502 #[test]
503 fn test_daemon_id_ordering() {
504 let id1 = DaemonId::new("a", "x");
505 let id2 = DaemonId::new("a", "y");
506 let id3 = DaemonId::new("b", "x");
507
508 assert!(id1 < id2);
509 assert!(id2 < id3);
510 assert!(id1 < id3);
511 }
512
513 #[test]
515 fn test_from_safe_path_double_dash_in_namespace_rejected() {
516 assert!(DaemonId::from_safe_path("my--project--api").is_err());
520 assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
521 }
522
523 #[test]
524 fn test_from_safe_path_roundtrip_via_qualified() {
525 let id = DaemonId::from_safe_path("global--api").unwrap();
527 assert_eq!(id.namespace(), "global");
528 assert_eq!(id.name(), "api");
529 let recovered = DaemonId::parse(&id.qualified()).unwrap();
531 assert_eq!(recovered, id);
532 }
533
534 #[test]
535 fn test_from_safe_path_no_separator() {
536 assert!(DaemonId::from_safe_path("globalapi").is_err());
538 assert!(DaemonId::from_safe_path("api").is_err());
539 }
540
541 #[test]
542 fn test_from_safe_path_empty_parts() {
543 let result = DaemonId::from_safe_path("--api");
545 assert!(result.is_err());
546
547 let result = DaemonId::from_safe_path("namespace--");
549 assert!(result.is_err());
550 }
551
552 #[test]
554 fn test_parse_cross_namespace_dependency() {
555 let id = DaemonId::parse("other-project/postgres").unwrap();
557 assert_eq!(id.namespace(), "other-project");
558 assert_eq!(id.name(), "postgres");
559 }
560
561 #[test]
563 fn test_directory_with_double_dash_in_name() {
564 let result = DaemonId::try_new("my--project", "api");
566 assert!(result.is_err());
567
568 let result = DaemonId::from_safe_path("my--project--api");
572 assert!(
573 result.is_err(),
574 "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
575 );
576 }
577
578 #[test]
579 fn test_parse_dot_namespace_rejected() {
580 let result = DaemonId::parse("./api");
583 assert!(result.is_err());
584
585 let result = DaemonId::parse("../api");
587 assert!(result.is_err());
588 }
589
590 #[test]
592 fn test_daemon_id_toml_roundtrip() {
593 #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
594 struct TestConfig {
595 daemon_id: DaemonId,
596 }
597
598 let config = TestConfig {
599 daemon_id: DaemonId::new("my-project", "api"),
600 };
601
602 let toml_str = toml::to_string(&config).unwrap();
603 assert!(toml_str.contains("daemon_id = \"my-project/api\""));
604
605 let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
606 assert_eq!(config, recovered);
607 }
608
609 #[test]
610 fn test_daemon_id_json_roundtrip_in_map() {
611 use std::collections::HashMap;
612
613 let mut map: HashMap<String, DaemonId> = HashMap::new();
614 map.insert("primary".to_string(), DaemonId::new("global", "api"));
615 map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
616
617 let json = serde_json::to_string(&map).unwrap();
618 let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
619 assert_eq!(map, recovered);
620 }
621
622 #[test]
624 fn test_pitchfork_id() {
625 let id = DaemonId::pitchfork();
626 assert_eq!(id.namespace(), "global");
627 assert_eq!(id.name(), "pitchfork");
628 assert_eq!(id.qualified(), "global/pitchfork");
629 }
630
631 #[test]
633 fn test_daemon_id_rejects_unicode() {
634 assert!(DaemonId::try_new("プロジェクト", "api").is_err());
635 assert!(DaemonId::try_new("project", "工作者").is_err());
636 }
637
638 #[test]
639 fn test_daemon_id_rejects_control_chars() {
640 assert!(DaemonId::try_new("project\x00", "api").is_err());
641 assert!(DaemonId::try_new("project", "api\x1b").is_err());
642 }
643
644 #[test]
645 fn test_daemon_id_rejects_spaces() {
646 assert!(DaemonId::try_new("my project", "api").is_err());
647 assert!(DaemonId::try_new("project", "my api").is_err());
648 assert!(DaemonId::parse("my project/api").is_err());
649 }
650
651 #[test]
652 fn test_daemon_id_rejects_chars_outside_schema_pattern() {
653 assert!(DaemonId::try_new("project+alpha", "api").is_err());
655 assert!(DaemonId::try_new("project", "api@v1").is_err());
656 }
657
658 #[test]
659 fn test_daemon_id_rejects_leading_trailing_dash() {
660 assert!(DaemonId::try_new("-project", "api").is_err());
662 assert!(DaemonId::try_new("project", "-api").is_err());
663 assert!(DaemonId::try_new("project-", "api").is_err());
665 assert!(DaemonId::try_new("project", "api-").is_err());
666 let id = DaemonId::try_new("a", "b").unwrap();
668 let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
669 assert_eq!(id, recovered);
670 assert!(DaemonId::from_safe_path("a---b").is_err()); }
673
674 #[test]
675 fn test_daemon_id_rejects_parent_dir_traversal() {
676 assert!(DaemonId::try_new("project", "..").is_err());
677 assert!(DaemonId::try_new("..", "api").is_err());
678 assert!(DaemonId::parse("../api").is_err());
679 assert!(DaemonId::parse("project/..").is_err());
680 }
681
682 #[test]
683 fn test_daemon_id_rejects_current_dir() {
684 assert!(DaemonId::try_new(".", "api").is_err());
685 assert!(DaemonId::try_new("project", ".").is_err());
686 }
687
688 #[test]
690 fn test_daemon_id_hash_consistency() {
691 use std::collections::HashSet;
692
693 let id1 = DaemonId::new("project", "api");
694 let id2 = DaemonId::new("project", "api");
695 let id3 = DaemonId::parse("project/api").unwrap();
696
697 let mut set = HashSet::new();
698 set.insert(id1.clone());
699
700 assert!(set.contains(&id2));
702 assert!(set.contains(&id3));
703
704 assert_eq!(id1, id2);
706 assert_eq!(id2, id3);
707 }
708}