Skip to main content

pitchfork_cli/
daemon_id.rs

1//! Structured daemon ID type that separates namespace and name.
2//!
3//! This module provides a type-safe representation of daemon IDs that
4//! eliminates the need for repeated parsing and formatting operations.
5
6use crate::Result;
7use crate::error::DaemonIdError;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt::{self, Display};
10use std::hash::Hash;
11
12/// A structured daemon identifier consisting of a namespace and a name.
13///
14/// All daemons have a namespace - global daemons use "global" as their namespace.
15/// This type eliminates the need to repeatedly parse and format daemon IDs.
16///
17/// # Formats
18///
19/// - **Qualified format**: `namespace/name` (e.g., `project-a/api`, `global/web`)
20/// - **Safe path format**: `namespace--name` (for filesystem paths)
21///
22/// # Examples
23///
24/// ```
25/// use pitchfork_cli::daemon_id::DaemonId;
26///
27/// let id = DaemonId::try_new("project-a", "api").unwrap();
28/// assert_eq!(id.namespace(), "project-a");
29/// assert_eq!(id.name(), "api");
30/// assert_eq!(id.qualified(), "project-a/api");
31/// assert_eq!(id.safe_path(), "project-a--api");
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct DaemonId {
35    namespace: String,
36    name: String,
37}
38
39impl DaemonId {
40    /// Creates a new DaemonId from namespace and name.
41    ///
42    /// # Panics
43    ///
44    /// Panics if either the namespace or name is invalid (contains invalid characters,
45    /// is empty, contains `--`, etc.). Use `try_new()` for a non-panicking version.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use pitchfork_cli::daemon_id::DaemonId;
51    ///
52    /// let id = DaemonId::new("global", "api");
53    /// ```
54    #[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        // Validate inputs - panic on invalid values
60        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    /// Creates a new DaemonId without validation.
71    ///
72    /// # Safety
73    ///
74    /// This function does not validate the inputs. Use it only when you are certain
75    /// the namespace and name are valid (e.g., when reading from a trusted source
76    /// like a parsed safe_path with "--" in the namespace component).
77    ///
78    /// For user-provided input, use `new()` or `try_new()` instead.
79    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    /// Creates a new DaemonId with validation.
87    ///
88    /// Returns an error if either the namespace or name is invalid.
89    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    /// Parses a qualified daemon ID string into a DaemonId.
100    ///
101    /// The input must be in the format `namespace/name`.
102    ///
103    /// # Examples
104    ///
105    /// ```
106    /// use pitchfork_cli::daemon_id::DaemonId;
107    ///
108    /// let id = DaemonId::parse("project-a/api").unwrap();
109    /// assert_eq!(id.namespace(), "project-a");
110    /// assert_eq!(id.name(), "api");
111    /// ```
112    pub fn parse(s: &str) -> Result<Self> {
113        validate_qualified_id(s)?;
114
115        // validate_qualified_id ensures exactly one '/' is present, so this unwrap is safe.
116        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    /// Creates a DaemonId from a filesystem-safe path component.
126    ///
127    /// Converts `namespace--name` format back to a DaemonId.
128    /// Both components are validated with the same rules as `try_new()`,
129    /// ensuring that the result can always be serialized and deserialized
130    /// through the qualified (`namespace/name`) format without error.
131    ///
132    /// # Examples
133    ///
134    /// ```
135    /// use pitchfork_cli::daemon_id::DaemonId;
136    ///
137    /// let id = DaemonId::from_safe_path("project-a--api").unwrap();
138    /// assert_eq!(id.qualified(), "project-a/api");
139    /// assert_eq!(DaemonId::parse(&id.qualified()).unwrap(), id);
140    ///
141    /// // Empty namespace or name fails validation
142    /// assert!(DaemonId::from_safe_path("--api").is_err());
143    /// assert!(DaemonId::from_safe_path("namespace--").is_err());
144    /// // Namespace containing "--" is rejected to preserve roundtrip
145    /// assert!(DaemonId::from_safe_path("my--project--api").is_err());
146    /// ```
147    pub fn from_safe_path(s: &str) -> Result<Self> {
148        if let Some((ns, name)) = s.split_once("--") {
149            // Validate both components with the same rules as try_new().
150            // This guarantees that qualified() output can always be re-parsed,
151            // preserving the Serialize <-> Deserialize roundtrip contract.
152            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    /// Returns the namespace portion of the daemon ID.
167    pub fn namespace(&self) -> &str {
168        &self.namespace
169    }
170
171    /// Returns a DaemonId for the pitchfork supervisor itself.
172    ///
173    /// This is a convenience method to avoid repeated `DaemonId::new("global", "pitchfork")` calls.
174    pub fn pitchfork() -> Self {
175        // Use new_unchecked for this constant value to avoid redundant validation
176        Self::new_unchecked("global", "pitchfork")
177    }
178
179    /// Returns the name (short ID) portion of the daemon ID.
180    pub fn name(&self) -> &str {
181        &self.name
182    }
183
184    /// Returns the qualified format: `namespace/name`.
185    pub fn qualified(&self) -> String {
186        format!("{}/{}", self.namespace, self.name)
187    }
188
189    /// Returns the filesystem-safe format: `namespace--name`.
190    pub fn safe_path(&self) -> String {
191        format!("{}--{}", self.namespace, self.name)
192    }
193
194    /// Returns the main log file path for this daemon.
195    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    /// Returns a styled display name for terminal output (stdout).
203    ///
204    /// The namespace part is displayed in dim color, followed by `/` and the name.
205    /// If `all_ids` is provided and the name is unique, only the name is shown.
206    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    /// Returns the qualified format with dim namespace for terminal output (stdout).
223    ///
224    /// Format: `<dim>namespace</dim>/name`
225    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
237// NOTE: AsRef<str> and Borrow<str> implementations were intentionally removed.
238// The Borrow trait has a contract that if T: Borrow<U>, then T's Hash/Eq/Ord
239// must be consistent with U's. DaemonId derives Hash and Eq on both namespace
240// and name, so implementing Borrow<str> would violate this contract and cause
241// HashMap/HashSet lookups via &str to silently break due to hash mismatches.
242
243/// Serialize as qualified string "namespace/name"
244impl 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
253/// Deserialize from qualified string "namespace/name"
254impl<'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
264/// JSON Schema implementation for DaemonId
265///
266/// In `pitchfork.toml`, users write **short names** (e.g. `api`) for daemon
267/// keys under `[daemons]` and for same-namespace `depends` entries.  Fully
268/// qualified `namespace/name` format is only required for cross-namespace
269/// dependency references.  The pattern therefore accepts both forms.
270impl 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
288/// Validates a single component (namespace or name) of a daemon ID.
289fn 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
334/// Validates a qualified daemon ID string.
335fn 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    // Check slash count
354    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    // Check both parts are non-empty
367    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 each component individually
377    // This ensures parse("./api") fails just like try_new(".", "api")
378    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        // Missing namespace should fail
404        assert!(DaemonId::parse("api").is_err());
405
406        // Empty parts should fail
407        assert!(DaemonId::parse("/api").is_err());
408        assert!(DaemonId::parse("project/").is_err());
409
410        // Multiple slashes should fail
411        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        // No separator should fail
421        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        // Valid IDs
451        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        // Invalid - contains reserved sequences
456        assert!(DaemonId::try_new("my--project", "api").is_err());
457        assert!(DaemonId::try_new("project", "my--daemon").is_err());
458
459        // Invalid - contains path separators
460        assert!(DaemonId::try_new("my/project", "api").is_err());
461        assert!(DaemonId::try_new("project", "my/daemon").is_err());
462
463        // Invalid - empty
464        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        // "api" is ambiguous → full qualified ID must appear in the output
477        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        // "worker" is unique → only the short name
489        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    // Edge case tests for from_safe_path
505    #[test]
506    fn test_from_safe_path_double_dash_in_namespace_rejected() {
507        // Namespaces containing "--" are rejected to preserve the Serialize <->
508        // Deserialize roundtrip: qualified() output must always be re-parseable.
509        // namespace_from_path() already sanitizes "--" -> "-" before reaching here.
510        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        // Standard case - single "--" separator, full roundtrip via qualified()
517        let id = DaemonId::from_safe_path("global--api").unwrap();
518        assert_eq!(id.namespace(), "global");
519        assert_eq!(id.name(), "api");
520        // Must roundtrip through qualified format (Serialize <-> Deserialize)
521        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        // No "--" at all - should fail
528        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        // Empty namespace (starts with --) - should fail validation
535        let result = DaemonId::from_safe_path("--api");
536        assert!(result.is_err());
537
538        // Empty name (ends with --) - should fail validation
539        let result = DaemonId::from_safe_path("namespace--");
540        assert!(result.is_err());
541    }
542
543    // Cross-namespace dependency parsing tests
544    #[test]
545    fn test_parse_cross_namespace_dependency() {
546        // Can parse fully qualified dependency reference
547        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 for directory names containing -- (namespace sanitization)
553    #[test]
554    fn test_directory_with_double_dash_in_name() {
555        // Directory names like "my--project" are invalid for try_new because -- is reserved
556        let result = DaemonId::try_new("my--project", "api");
557        assert!(result.is_err());
558
559        // from_safe_path also rejects "--" in namespace to preserve Serialize <->
560        // Deserialize roundtrip. namespace_from_path() sanitizes "--" to "-" before
561        // writing to the filesystem, so this case never arises in practice.
562        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        // parse("./api") should fail because "." is invalid as namespace
572        // This ensures consistency with try_new(".", "api") which also fails
573        let result = DaemonId::parse("./api");
574        assert!(result.is_err());
575
576        // Also test ".." as namespace
577        let result = DaemonId::parse("../api");
578        assert!(result.is_err());
579    }
580
581    // Serialization roundtrip tests
582    #[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    // Pitchfork special ID test
614    #[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    // Unicode and special character tests
623    #[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        // Schema only allows [A-Za-z0-9_.-] for each component.
645        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        // Leading dash in namespace or name
652        assert!(DaemonId::try_new("-project", "api").is_err());
653        assert!(DaemonId::try_new("project", "-api").is_err());
654        // Trailing dash in namespace or name
655        assert!(DaemonId::try_new("project-", "api").is_err());
656        assert!(DaemonId::try_new("project", "api-").is_err());
657        // Verify the safe_path roundtrip invariant holds for names with internal dashes
658        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        // from_safe_path must also reject names produced by invalid components
662        assert!(DaemonId::from_safe_path("a---b").is_err()); // came from "a-"/"b" or "a"/"-b"
663    }
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    // Hash and equality tests for HashMap usage
680    #[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        // Same ID constructed differently should be found
692        assert!(set.contains(&id2));
693        assert!(set.contains(&id3));
694
695        // Verify they're all equal
696        assert_eq!(id1, id2);
697        assert_eq!(id2, id3);
698    }
699}