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"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$",
293 "not": {
294 "pattern": r"\.\.|--|(^|/)-|-($|/)|^\.$|^\./|/\.$"
295 }
296 })
297 }
298}
299
300fn validate_component(s: &str, component_name: &str) -> Result<()> {
302 if s.is_empty() {
303 return Err(DaemonIdError::EmptyComponent {
304 component: component_name.to_string(),
305 }
306 .into());
307 }
308 if s.contains('/') {
309 return Err(DaemonIdError::PathSeparator {
310 id: s.to_string(),
311 sep: '/',
312 }
313 .into());
314 }
315 if s.contains('\\') {
316 return Err(DaemonIdError::PathSeparator {
317 id: s.to_string(),
318 sep: '\\',
319 }
320 .into());
321 }
322 if s.contains("..") {
323 return Err(DaemonIdError::ParentDirRef { id: s.to_string() }.into());
324 }
325 if s.contains("--") {
326 return Err(DaemonIdError::ReservedSequence { id: s.to_string() }.into());
327 }
328 if s.starts_with('-') || s.ends_with('-') {
329 return Err(DaemonIdError::LeadingTrailingDash { id: s.to_string() }.into());
330 }
331 if s.contains(' ') {
332 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
333 }
334 if s == "." {
335 return Err(DaemonIdError::CurrentDir.into());
336 }
337 if !s
338 .chars()
339 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
340 {
341 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
342 }
343 Ok(())
344}
345
346fn validate_qualified_id(s: &str) -> Result<()> {
348 if s.is_empty() {
349 return Err(DaemonIdError::Empty.into());
350 }
351 if s.contains('\\') {
352 return Err(DaemonIdError::PathSeparator {
353 id: s.to_string(),
354 sep: '\\',
355 }
356 .into());
357 }
358 if s.contains(' ') {
359 return Err(DaemonIdError::ContainsSpace { id: s.to_string() }.into());
360 }
361 if !s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
362 return Err(DaemonIdError::InvalidChars { id: s.to_string() }.into());
363 }
364
365 let slash_count = s.chars().filter(|&c| c == '/').count();
367 if slash_count == 0 {
368 return Err(DaemonIdError::MissingNamespace { id: s.to_string() }.into());
369 }
370 if slash_count > 1 {
371 return Err(DaemonIdError::PathSeparator {
372 id: s.to_string(),
373 sep: '/',
374 }
375 .into());
376 }
377
378 let (ns, name) = s.split_once('/').unwrap();
380 if ns.is_empty() || name.is_empty() {
381 return Err(DaemonIdError::PathSeparator {
382 id: s.to_string(),
383 sep: '/',
384 }
385 .into());
386 }
387
388 validate_component(ns, "namespace")?;
391 validate_component(name, "name")?;
392
393 Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_daemon_id_new() {
402 let id = DaemonId::new("global", "api");
403 assert_eq!(id.namespace(), "global");
404 assert_eq!(id.name(), "api");
405 assert_eq!(id.qualified(), "global/api");
406 assert_eq!(id.safe_path(), "global--api");
407 }
408
409 #[test]
410 fn test_daemon_id_parse() {
411 let id = DaemonId::parse("project-a/api").unwrap();
412 assert_eq!(id.namespace(), "project-a");
413 assert_eq!(id.name(), "api");
414
415 assert!(DaemonId::parse("api").is_err());
417
418 assert!(DaemonId::parse("/api").is_err());
420 assert!(DaemonId::parse("project/").is_err());
421
422 assert!(DaemonId::parse("a/b/c").is_err());
424 }
425
426 #[test]
427 fn test_daemon_id_from_safe_path() {
428 let id = DaemonId::from_safe_path("project-a--api").unwrap();
429 assert_eq!(id.namespace(), "project-a");
430 assert_eq!(id.name(), "api");
431
432 assert!(DaemonId::from_safe_path("projectapi").is_err());
434 }
435
436 #[test]
437 fn test_daemon_id_roundtrip() {
438 let original = DaemonId::new("my-project", "my-daemon");
439 let safe = original.safe_path();
440 let recovered = DaemonId::from_safe_path(&safe).unwrap();
441 assert_eq!(original, recovered);
442 }
443
444 #[test]
445 fn test_daemon_id_display() {
446 let id = DaemonId::new("global", "api");
447 assert_eq!(format!("{id}"), "global/api");
448 }
449
450 #[test]
451 fn test_daemon_id_serialize() {
452 let id = DaemonId::new("global", "api");
453 let json = serde_json::to_string(&id).unwrap();
454 assert_eq!(json, "\"global/api\"");
455
456 let deserialized: DaemonId = serde_json::from_str(&json).unwrap();
457 assert_eq!(id, deserialized);
458 }
459
460 #[test]
461 fn test_daemon_id_validation() {
462 assert!(DaemonId::try_new("global", "api").is_ok());
464 assert!(DaemonId::try_new("my-project", "my-daemon").is_ok());
465 assert!(DaemonId::try_new("project_a", "daemon_1").is_ok());
466
467 assert!(DaemonId::try_new("my--project", "api").is_err());
469 assert!(DaemonId::try_new("project", "my--daemon").is_err());
470
471 assert!(DaemonId::try_new("my/project", "api").is_err());
473 assert!(DaemonId::try_new("project", "my/daemon").is_err());
474
475 assert!(DaemonId::try_new("", "api").is_err());
477 assert!(DaemonId::try_new("project", "").is_err());
478 }
479
480 #[test]
481 fn test_daemon_id_styled_display_name() {
482 let id1 = DaemonId::new("project-a", "api");
483 let id2 = DaemonId::new("project-b", "api");
484 let id3 = DaemonId::new("global", "worker");
485
486 let all_ids = [&id1, &id2, &id3];
487
488 let out1 = id1.styled_display_name(Some(all_ids.iter().copied()));
490 let out2 = id2.styled_display_name(Some(all_ids.iter().copied()));
491 assert!(
492 out1.contains("project-a") && out1.contains("api"),
493 "ambiguous id1 should show namespace: {out1}"
494 );
495 assert!(
496 out2.contains("project-b") && out2.contains("api"),
497 "ambiguous id2 should show namespace: {out2}"
498 );
499
500 let out3 = id3.styled_display_name(Some(all_ids.iter().copied()));
502 assert_eq!(out3, "worker", "unique id3 should show only short name");
503 }
504
505 #[test]
506 fn test_daemon_id_ordering() {
507 let id1 = DaemonId::new("a", "x");
508 let id2 = DaemonId::new("a", "y");
509 let id3 = DaemonId::new("b", "x");
510
511 assert!(id1 < id2);
512 assert!(id2 < id3);
513 assert!(id1 < id3);
514 }
515
516 #[test]
518 fn test_from_safe_path_double_dash_in_namespace_rejected() {
519 assert!(DaemonId::from_safe_path("my--project--api").is_err());
523 assert!(DaemonId::from_safe_path("a--b--c--daemon").is_err());
524 }
525
526 #[test]
527 fn test_from_safe_path_roundtrip_via_qualified() {
528 let id = DaemonId::from_safe_path("global--api").unwrap();
530 assert_eq!(id.namespace(), "global");
531 assert_eq!(id.name(), "api");
532 let recovered = DaemonId::parse(&id.qualified()).unwrap();
534 assert_eq!(recovered, id);
535 }
536
537 #[test]
538 fn test_from_safe_path_no_separator() {
539 assert!(DaemonId::from_safe_path("globalapi").is_err());
541 assert!(DaemonId::from_safe_path("api").is_err());
542 }
543
544 #[test]
545 fn test_from_safe_path_empty_parts() {
546 let result = DaemonId::from_safe_path("--api");
548 assert!(result.is_err());
549
550 let result = DaemonId::from_safe_path("namespace--");
552 assert!(result.is_err());
553 }
554
555 #[test]
557 fn test_parse_cross_namespace_dependency() {
558 let id = DaemonId::parse("other-project/postgres").unwrap();
560 assert_eq!(id.namespace(), "other-project");
561 assert_eq!(id.name(), "postgres");
562 }
563
564 #[test]
566 fn test_directory_with_double_dash_in_name() {
567 let result = DaemonId::try_new("my--project", "api");
569 assert!(result.is_err());
570
571 let result = DaemonId::from_safe_path("my--project--api");
575 assert!(
576 result.is_err(),
577 "from_safe_path must reject '--' in namespace to guarantee roundtrip via qualified()"
578 );
579 }
580
581 #[test]
582 fn test_parse_dot_namespace_rejected() {
583 let result = DaemonId::parse("./api");
586 assert!(result.is_err());
587
588 let result = DaemonId::parse("../api");
590 assert!(result.is_err());
591 }
592
593 #[test]
595 fn test_daemon_id_toml_roundtrip() {
596 #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
597 struct TestConfig {
598 daemon_id: DaemonId,
599 }
600
601 let config = TestConfig {
602 daemon_id: DaemonId::new("my-project", "api"),
603 };
604
605 let toml_str = toml::to_string(&config).unwrap();
606 assert!(toml_str.contains("daemon_id = \"my-project/api\""));
607
608 let recovered: TestConfig = toml::from_str(&toml_str).unwrap();
609 assert_eq!(config, recovered);
610 }
611
612 #[test]
613 fn test_daemon_id_json_roundtrip_in_map() {
614 use std::collections::HashMap;
615
616 let mut map: HashMap<String, DaemonId> = HashMap::new();
617 map.insert("primary".to_string(), DaemonId::new("global", "api"));
618 map.insert("secondary".to_string(), DaemonId::new("project", "worker"));
619
620 let json = serde_json::to_string(&map).unwrap();
621 let recovered: HashMap<String, DaemonId> = serde_json::from_str(&json).unwrap();
622 assert_eq!(map, recovered);
623 }
624
625 #[test]
627 fn test_pitchfork_id() {
628 let id = DaemonId::pitchfork();
629 assert_eq!(id.namespace(), "global");
630 assert_eq!(id.name(), "pitchfork");
631 assert_eq!(id.qualified(), "global/pitchfork");
632 }
633
634 #[test]
636 fn test_daemon_id_rejects_unicode() {
637 assert!(DaemonId::try_new("プロジェクト", "api").is_err());
638 assert!(DaemonId::try_new("project", "工作者").is_err());
639 }
640
641 #[test]
642 fn test_daemon_id_rejects_control_chars() {
643 assert!(DaemonId::try_new("project\x00", "api").is_err());
644 assert!(DaemonId::try_new("project", "api\x1b").is_err());
645 }
646
647 #[test]
648 fn test_daemon_id_rejects_spaces() {
649 assert!(DaemonId::try_new("my project", "api").is_err());
650 assert!(DaemonId::try_new("project", "my api").is_err());
651 assert!(DaemonId::parse("my project/api").is_err());
652 }
653
654 #[test]
655 fn test_daemon_id_rejects_chars_outside_schema_pattern() {
656 assert!(DaemonId::try_new("project+alpha", "api").is_err());
658 assert!(DaemonId::try_new("project", "api@v1").is_err());
659 }
660
661 #[test]
662 fn test_daemon_id_rejects_leading_trailing_dash() {
663 assert!(DaemonId::try_new("-project", "api").is_err());
665 assert!(DaemonId::try_new("project", "-api").is_err());
666 assert!(DaemonId::try_new("project-", "api").is_err());
668 assert!(DaemonId::try_new("project", "api-").is_err());
669 let id = DaemonId::try_new("a", "b").unwrap();
671 let recovered = DaemonId::from_safe_path(&id.safe_path()).unwrap();
672 assert_eq!(id, recovered);
673 assert!(DaemonId::from_safe_path("a---b").is_err()); }
676
677 #[test]
678 fn test_daemon_id_rejects_parent_dir_traversal() {
679 assert!(DaemonId::try_new("project", "..").is_err());
680 assert!(DaemonId::try_new("..", "api").is_err());
681 assert!(DaemonId::parse("../api").is_err());
682 assert!(DaemonId::parse("project/..").is_err());
683 }
684
685 #[test]
686 fn test_daemon_id_rejects_current_dir() {
687 assert!(DaemonId::try_new(".", "api").is_err());
688 assert!(DaemonId::try_new("project", ".").is_err());
689 }
690
691 #[test]
693 fn test_daemon_id_hash_consistency() {
694 use std::collections::HashSet;
695
696 let id1 = DaemonId::new("project", "api");
697 let id2 = DaemonId::new("project", "api");
698 let id3 = DaemonId::parse("project/api").unwrap();
699
700 let mut set = HashSet::new();
701 set.insert(id1.clone());
702
703 assert!(set.contains(&id2));
705 assert!(set.contains(&id3));
706
707 assert_eq!(id1, id2);
709 assert_eq!(id2, id3);
710 }
711}