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 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    /// Creates a new DaemonId from namespace and name.
50    ///
51    /// # Panics
52    ///
53    /// Panics if either the namespace or name is invalid (contains invalid characters,
54    /// is empty, contains `--`, etc.). Use `try_new()` for a non-panicking version.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use pitchfork_cli::daemon_id::DaemonId;
60    ///
61    /// let id = DaemonId::new("global", "api");
62    /// ```
63    #[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        // Validate inputs - panic on invalid values
69        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    /// Creates a new DaemonId without validation.
80    ///
81    /// # Safety
82    ///
83    /// This function does not validate the inputs. Use it only when you are certain
84    /// the namespace and name are valid (e.g., when reading from a trusted source
85    /// like a parsed safe_path with "--" in the namespace component).
86    ///
87    /// For user-provided input, use `new()` or `try_new()` instead.
88    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    /// Creates a new DaemonId with validation.
96    ///
97    /// Returns an error if either the namespace or name is invalid.
98    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    /// Parses a qualified daemon ID string into a DaemonId.
109    ///
110    /// The input must be in the format `namespace/name`.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use pitchfork_cli::daemon_id::DaemonId;
116    ///
117    /// let id = DaemonId::parse("project-a/api").unwrap();
118    /// assert_eq!(id.namespace(), "project-a");
119    /// assert_eq!(id.name(), "api");
120    /// ```
121    pub fn parse(s: &str) -> Result<Self> {
122        validate_qualified_id(s)?;
123
124        // validate_qualified_id ensures exactly one '/' is present, so this unwrap is safe.
125        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    /// Creates a DaemonId from a filesystem-safe path component.
135    ///
136    /// Converts `namespace--name` format back to a DaemonId.
137    /// Both components are validated with the same rules as `try_new()`,
138    /// ensuring that the result can always be serialized and deserialized
139    /// through the qualified (`namespace/name`) format without error.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use pitchfork_cli::daemon_id::DaemonId;
145    ///
146    /// let id = DaemonId::from_safe_path("project-a--api").unwrap();
147    /// assert_eq!(id.qualified(), "project-a/api");
148    /// assert_eq!(DaemonId::parse(&id.qualified()).unwrap(), id);
149    ///
150    /// // Empty namespace or name fails validation
151    /// assert!(DaemonId::from_safe_path("--api").is_err());
152    /// assert!(DaemonId::from_safe_path("namespace--").is_err());
153    /// // Namespace containing "--" is rejected to preserve roundtrip
154    /// assert!(DaemonId::from_safe_path("my--project--api").is_err());
155    /// ```
156    pub fn from_safe_path(s: &str) -> Result<Self> {
157        if let Some((ns, name)) = s.split_once("--") {
158            // Validate both components with the same rules as try_new().
159            // This guarantees that qualified() output can always be re-parsed,
160            // preserving the Serialize <-> Deserialize roundtrip contract.
161            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    /// Returns the namespace portion of the daemon ID.
176    pub fn namespace(&self) -> &str {
177        &self.namespace
178    }
179
180    /// Returns a DaemonId for the pitchfork supervisor itself.
181    ///
182    /// This is a convenience method to avoid repeated `DaemonId::new("global", "pitchfork")` calls.
183    pub fn pitchfork() -> Self {
184        // Use new_unchecked for this constant value to avoid redundant validation
185        Self::new_unchecked("global", "pitchfork")
186    }
187
188    /// Returns the name (short ID) portion of the daemon ID.
189    pub fn name(&self) -> &str {
190        &self.name
191    }
192
193    /// Returns the qualified format: `namespace/name`.
194    pub fn qualified(&self) -> String {
195        format!("{}/{}", self.namespace, self.name)
196    }
197
198    /// Returns the filesystem-safe format: `namespace--name`.
199    pub fn safe_path(&self) -> String {
200        format!("{}--{}", self.namespace, self.name)
201    }
202
203    /// Returns the main log file path for this daemon.
204    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    /// Returns a styled display name for terminal output (stdout).
212    ///
213    /// The namespace part is displayed in dim color, followed by `/` and the name.
214    /// If `all_ids` is provided and the name is unique, only the name is shown.
215    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    /// Returns the qualified format with dim namespace for terminal output (stdout).
232    ///
233    /// Format: `<dim>namespace</dim>/name`
234    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
246// NOTE: AsRef<str> and Borrow<str> implementations were intentionally removed.
247// The Borrow trait has a contract that if T: Borrow<U>, then T's Hash/Eq/Ord
248// must be consistent with U's. DaemonId derives Hash and Eq on both namespace
249// and name, so implementing Borrow<str> would violate this contract and cause
250// HashMap/HashSet lookups via &str to silently break due to hash mismatches.
251
252/// Serialize as qualified string "namespace/name"
253impl 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
262/// Deserialize from qualified string "namespace/name"
263impl<'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
273/// JSON Schema implementation for DaemonId
274///
275/// In `pitchfork.toml`, users write **short names** (e.g. `api`) for daemon
276/// keys under `[daemons]` and for same-namespace `depends` entries.  Fully
277/// qualified `namespace/name` format is only required for cross-namespace
278/// dependency references.  The pattern therefore accepts both forms.
279impl 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
297/// Validates a single component (namespace or name) of a daemon ID.
298fn 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
343/// Validates a qualified daemon ID string.
344fn 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    // Check slash count
363    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    // Check both parts are non-empty
376    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 each component individually
386    // This ensures parse("./api") fails just like try_new(".", "api")
387    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        // Missing namespace should fail
413        assert!(DaemonId::parse("api").is_err());
414
415        // Empty parts should fail
416        assert!(DaemonId::parse("/api").is_err());
417        assert!(DaemonId::parse("project/").is_err());
418
419        // Multiple slashes should fail
420        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        // No separator should fail
430        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        // Valid IDs
460        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        // Invalid - contains reserved sequences
465        assert!(DaemonId::try_new("my--project", "api").is_err());
466        assert!(DaemonId::try_new("project", "my--daemon").is_err());
467
468        // Invalid - contains path separators
469        assert!(DaemonId::try_new("my/project", "api").is_err());
470        assert!(DaemonId::try_new("project", "my/daemon").is_err());
471
472        // Invalid - empty
473        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        // "api" is ambiguous → full qualified ID must appear in the output
486        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        // "worker" is unique → only the short name
498        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    // Edge case tests for from_safe_path
514    #[test]
515    fn test_from_safe_path_double_dash_in_namespace_rejected() {
516        // Namespaces containing "--" are rejected to preserve the Serialize <->
517        // Deserialize roundtrip: qualified() output must always be re-parseable.
518        // namespace_from_path() already sanitizes "--" -> "-" before reaching here.
519        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        // Standard case - single "--" separator, full roundtrip via qualified()
526        let id = DaemonId::from_safe_path("global--api").unwrap();
527        assert_eq!(id.namespace(), "global");
528        assert_eq!(id.name(), "api");
529        // Must roundtrip through qualified format (Serialize <-> Deserialize)
530        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        // No "--" at all - should fail
537        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        // Empty namespace (starts with --) - should fail validation
544        let result = DaemonId::from_safe_path("--api");
545        assert!(result.is_err());
546
547        // Empty name (ends with --) - should fail validation
548        let result = DaemonId::from_safe_path("namespace--");
549        assert!(result.is_err());
550    }
551
552    // Cross-namespace dependency parsing tests
553    #[test]
554    fn test_parse_cross_namespace_dependency() {
555        // Can parse fully qualified dependency reference
556        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 for directory names containing -- (namespace sanitization)
562    #[test]
563    fn test_directory_with_double_dash_in_name() {
564        // Directory names like "my--project" are invalid for try_new because -- is reserved
565        let result = DaemonId::try_new("my--project", "api");
566        assert!(result.is_err());
567
568        // from_safe_path also rejects "--" in namespace to preserve Serialize <->
569        // Deserialize roundtrip. namespace_from_path() sanitizes "--" to "-" before
570        // writing to the filesystem, so this case never arises in practice.
571        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        // parse("./api") should fail because "." is invalid as namespace
581        // This ensures consistency with try_new(".", "api") which also fails
582        let result = DaemonId::parse("./api");
583        assert!(result.is_err());
584
585        // Also test ".." as namespace
586        let result = DaemonId::parse("../api");
587        assert!(result.is_err());
588    }
589
590    // Serialization roundtrip tests
591    #[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    // Pitchfork special ID test
623    #[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    // Unicode and special character tests
632    #[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        // Schema only allows [A-Za-z0-9_.-] for each component.
654        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        // Leading dash in namespace or name
661        assert!(DaemonId::try_new("-project", "api").is_err());
662        assert!(DaemonId::try_new("project", "-api").is_err());
663        // Trailing dash in namespace or name
664        assert!(DaemonId::try_new("project-", "api").is_err());
665        assert!(DaemonId::try_new("project", "api-").is_err());
666        // Verify the safe_path roundtrip invariant holds for names with internal dashes
667        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        // from_safe_path must also reject names produced by invalid components
671        assert!(DaemonId::from_safe_path("a---b").is_err()); // came from "a-"/"b" or "a"/"-b"
672    }
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    // Hash and equality tests for HashMap usage
689    #[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        // Same ID constructed differently should be found
701        assert!(set.contains(&id2));
702        assert!(set.contains(&id3));
703
704        // Verify they're all equal
705        assert_eq!(id1, id2);
706        assert_eq!(id2, id3);
707    }
708}