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"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$",
293            "not": {
294                "pattern": r"\.\.|--|(^|/)-|-($|/)|^\.$|^\./|/\.$"
295            }
296        })
297    }
298}
299
300/// Validates a single component (namespace or name) of a daemon ID.
301fn 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
346/// Validates a qualified daemon ID string.
347fn 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    // Check slash count
366    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    // Check both parts are non-empty
379    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 each component individually
389    // This ensures parse("./api") fails just like try_new(".", "api")
390    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        // Missing namespace should fail
416        assert!(DaemonId::parse("api").is_err());
417
418        // Empty parts should fail
419        assert!(DaemonId::parse("/api").is_err());
420        assert!(DaemonId::parse("project/").is_err());
421
422        // Multiple slashes should fail
423        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        // No separator should fail
433        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        // Valid IDs
463        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        // Invalid - contains reserved sequences
468        assert!(DaemonId::try_new("my--project", "api").is_err());
469        assert!(DaemonId::try_new("project", "my--daemon").is_err());
470
471        // Invalid - contains path separators
472        assert!(DaemonId::try_new("my/project", "api").is_err());
473        assert!(DaemonId::try_new("project", "my/daemon").is_err());
474
475        // Invalid - empty
476        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        // "api" is ambiguous → full qualified ID must appear in the output
489        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        // "worker" is unique → only the short name
501        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    // Edge case tests for from_safe_path
517    #[test]
518    fn test_from_safe_path_double_dash_in_namespace_rejected() {
519        // Namespaces containing "--" are rejected to preserve the Serialize <->
520        // Deserialize roundtrip: qualified() output must always be re-parseable.
521        // namespace_from_path() already sanitizes "--" -> "-" before reaching here.
522        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        // Standard case - single "--" separator, full roundtrip via qualified()
529        let id = DaemonId::from_safe_path("global--api").unwrap();
530        assert_eq!(id.namespace(), "global");
531        assert_eq!(id.name(), "api");
532        // Must roundtrip through qualified format (Serialize <-> Deserialize)
533        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        // No "--" at all - should fail
540        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        // Empty namespace (starts with --) - should fail validation
547        let result = DaemonId::from_safe_path("--api");
548        assert!(result.is_err());
549
550        // Empty name (ends with --) - should fail validation
551        let result = DaemonId::from_safe_path("namespace--");
552        assert!(result.is_err());
553    }
554
555    // Cross-namespace dependency parsing tests
556    #[test]
557    fn test_parse_cross_namespace_dependency() {
558        // Can parse fully qualified dependency reference
559        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 for directory names containing -- (namespace sanitization)
565    #[test]
566    fn test_directory_with_double_dash_in_name() {
567        // Directory names like "my--project" are invalid for try_new because -- is reserved
568        let result = DaemonId::try_new("my--project", "api");
569        assert!(result.is_err());
570
571        // from_safe_path also rejects "--" in namespace to preserve Serialize <->
572        // Deserialize roundtrip. namespace_from_path() sanitizes "--" to "-" before
573        // writing to the filesystem, so this case never arises in practice.
574        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        // parse("./api") should fail because "." is invalid as namespace
584        // This ensures consistency with try_new(".", "api") which also fails
585        let result = DaemonId::parse("./api");
586        assert!(result.is_err());
587
588        // Also test ".." as namespace
589        let result = DaemonId::parse("../api");
590        assert!(result.is_err());
591    }
592
593    // Serialization roundtrip tests
594    #[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    // Pitchfork special ID test
626    #[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    // Unicode and special character tests
635    #[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        // Schema only allows [A-Za-z0-9_.-] for each component.
657        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        // Leading dash in namespace or name
664        assert!(DaemonId::try_new("-project", "api").is_err());
665        assert!(DaemonId::try_new("project", "-api").is_err());
666        // Trailing dash in namespace or name
667        assert!(DaemonId::try_new("project-", "api").is_err());
668        assert!(DaemonId::try_new("project", "api-").is_err());
669        // Verify the safe_path roundtrip invariant holds for names with internal dashes
670        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        // from_safe_path must also reject names produced by invalid components
674        assert!(DaemonId::from_safe_path("a---b").is_err()); // came from "a-"/"b" or "a"/"-b"
675    }
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    // Hash and equality tests for HashMap usage
692    #[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        // Same ID constructed differently should be found
704        assert!(set.contains(&id2));
705        assert!(set.contains(&id3));
706
707        // Verify they're all equal
708        assert_eq!(id1, id2);
709        assert_eq!(id2, id3);
710    }
711}