Skip to main content

storage/worktree/
name.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Worktree names: one segment, no escape.
7//!
8//! Specified by `crates/storage/spec/Worktree.lean` §8-9.
9
10use crate::error::StorageError;
11
12/// A node's name for its tree. A newtype because this string becomes a
13/// directory name and a git reference: the two ways it can be malformed
14/// are checked once, here.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub struct WorktreeName(String);
17
18impl WorktreeName {
19    /// # Errors
20    /// Refuses an empty name, a path separator, a leading dot, and any
21    /// character outside `[A-Za-z0-9._-]`. A name that walks out of its
22    /// directory is the whole isolation guarantee walking out with it.
23    pub fn parse(raw: &str) -> Result<WorktreeName, StorageError> {
24        let refuse = |detail: &str| StorageError::Worktree {
25            op: "name a worktree",
26            detail: format!("{raw}: {detail}"),
27        };
28        if raw.is_empty() {
29            return Err(refuse("a tree with no name cannot be released either"));
30        }
31        if raw.starts_with('.') {
32            return Err(refuse("names do not start with a dot"));
33        }
34        if !raw
35            .chars()
36            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
37        {
38            return Err(refuse("letters, digits, dot, underscore and dash only"));
39        }
40        Ok(WorktreeName(raw.to_owned()))
41    }
42
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49#[cfg(test)]
50#[allow(
51    clippy::unwrap_used,
52    clippy::expect_used,
53    clippy::panic,
54    clippy::indexing_slicing,
55    reason = "test code"
56)]
57mod tests {
58    use super::*;
59    #[test]
60    fn a_name_that_could_walk_out_of_its_directory_is_not_a_name() {
61        for raw in ["", ".hidden", "../escape", "node/1", "node 1", "nöde"] {
62            assert!(
63                WorktreeName::parse(raw).is_err(),
64                "{raw:?} is not a worktree name"
65            );
66        }
67        assert_eq!(
68            WorktreeName::parse("node-1.2_3").unwrap().as_str(),
69            "node-1.2_3"
70        );
71    }
72}