Skip to main content

treeboot_core/
init.rs

1use std::io::ErrorKind;
2use std::path::{Path, PathBuf};
3
4use crate::context::resolve_worktree_path;
5use crate::{Error, OutputEvent, Reporter, Result};
6
7const DEFAULT_CONFIG_PATH: &str = ".treeboot.toml";
8const DEFAULT_SCRIPT_PATH: &str = ".treeboot.sh";
9
10const STARTER_CONFIG: &str = r#"strict = false
11dangerously_allow_sources_outside_root = false
12dangerously_allow_targets_outside_worktree = false
13
14copy = [
15  ".env",
16]
17
18symlink = [
19]
20
21commands = [
22]
23"#;
24
25const STARTER_SCRIPT: &str = r#"#!/usr/bin/env sh
26set -eu
27
28root_path="$1"
29
30printf 'treeboot root: %s\n' "$root_path"
31"#;
32
33/// Init file type to create.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum InitKind {
36    /// Create a starter TOML config.
37    #[default]
38    Config,
39    /// Create an executable init script.
40    Script,
41}
42
43/// Options for `treeboot init`.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct InitOptions {
46    /// Directory in which the init target is created.
47    pub cwd: Option<PathBuf>,
48    /// Init file type to create. Defaults to a starter TOML config.
49    pub kind: InitKind,
50    /// Output path. Defaults depend on the selected kind.
51    pub path: Option<PathBuf>,
52}
53
54/// Result summary for `treeboot init`.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct InitReport {
57    /// Init file type that was created.
58    pub kind: InitKind,
59    /// Created path.
60    pub path: PathBuf,
61}
62
63/// Creates a starter treeboot config or init script.
64///
65/// Writes the selected init artifact to the requested path, or to the default
66/// path for its kind. Script artifacts are marked executable on Unix.
67///
68/// # Errors
69///
70/// Returns an error if the current directory cannot be resolved, the target
71/// already exists, or the target directory or file cannot be written.
72pub fn init(options: InitOptions, reporter: &mut dyn Reporter) -> Result<InitReport> {
73    let cwd = options.cwd.as_ref().map_or_else(
74        || std::env::current_dir().map_err(|source| Error::CurrentDir { source }),
75        |path| Ok(path.clone()),
76    )?;
77    let kind = options.kind;
78    let path = options.path.unwrap_or_else(|| default_path(kind));
79    let path = resolve_worktree_path(&cwd, &path);
80
81    if target_exists(&path)? {
82        return Err(Error::InitTargetExists(path));
83    }
84
85    if let Some(parent) = path.parent() {
86        std::fs::create_dir_all(parent).map_err(|source| Error::InitIo {
87            path: parent.to_path_buf(),
88            source,
89        })?;
90    }
91
92    let content = match kind {
93        InitKind::Config => STARTER_CONFIG,
94        InitKind::Script => STARTER_SCRIPT,
95    };
96
97    std::fs::write(&path, content).map_err(|source| Error::InitIo {
98        path: path.clone(),
99        source,
100    })?;
101
102    if kind == InitKind::Script {
103        make_executable(&path)?;
104    }
105
106    reporter
107        .report(OutputEvent::InitCreated { path: path.clone() })
108        .map_err(|source| Error::Output { source })?;
109
110    Ok(InitReport { kind, path })
111}
112
113fn default_path(kind: InitKind) -> PathBuf {
114    match kind {
115        InitKind::Config => PathBuf::from(DEFAULT_CONFIG_PATH),
116        InitKind::Script => PathBuf::from(DEFAULT_SCRIPT_PATH),
117    }
118}
119
120fn target_exists(path: &Path) -> Result<bool> {
121    match std::fs::symlink_metadata(path) {
122        Ok(_) => Ok(true),
123        Err(source) if source.kind() == ErrorKind::NotFound => Ok(false),
124        Err(source) => Err(Error::InitIo {
125            path: path.to_path_buf(),
126            source,
127        }),
128    }
129}
130
131#[cfg(unix)]
132fn make_executable(path: &std::path::Path) -> Result<()> {
133    use std::os::unix::fs::PermissionsExt;
134
135    let mut permissions = std::fs::metadata(path)
136        .map_err(|source| Error::InitIo {
137            path: path.to_path_buf(),
138            source,
139        })?
140        .permissions();
141    permissions.set_mode(permissions.mode() | 0o111);
142    std::fs::set_permissions(path, permissions).map_err(|source| Error::InitIo {
143        path: path.to_path_buf(),
144        source,
145    })
146}
147
148#[cfg(not(unix))]
149fn make_executable(_path: &std::path::Path) -> Result<()> {
150    Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    use tempfile::TempDir;
158
159    #[derive(Default)]
160    struct VecReporter {
161        events: Vec<OutputEvent>,
162    }
163
164    impl Reporter for VecReporter {
165        fn report(&mut self, event: OutputEvent) -> std::io::Result<()> {
166            self.events.push(event);
167            Ok(())
168        }
169    }
170
171    #[test]
172    fn init_should_refuse_existing_file() {
173        let dir = TempDir::new().expect("tempdir should be created");
174        let config = dir.path().join(".treeboot.toml");
175        std::fs::write(&config, "old\n").expect("config should be written");
176        let mut reporter = VecReporter::default();
177
178        let err = init(
179            InitOptions {
180                cwd: Some(dir.path().to_path_buf()),
181                kind: InitKind::Config,
182                path: None,
183            },
184            &mut reporter,
185        )
186        .expect_err("existing target should be rejected");
187
188        match err {
189            Error::InitTargetExists(path) => assert_eq!(path, config),
190            other => panic!("expected InitTargetExists, got {other:?}"),
191        }
192        assert_eq!(
193            std::fs::read_to_string(config).expect("config should be readable"),
194            "old\n"
195        );
196        assert!(reporter.events.is_empty());
197    }
198
199    #[cfg(unix)]
200    #[test]
201    fn init_should_refuse_existing_symlink_without_writing_through_it() {
202        use std::os::unix::fs::symlink;
203
204        let dir = TempDir::new().expect("tempdir should be created");
205        let target = dir.path().join("target.toml");
206        let link = dir.path().join(".treeboot.toml");
207        std::fs::write(&target, "old\n").expect("target should be written");
208        symlink(&target, &link).expect("symlink should be created");
209        let mut reporter = VecReporter::default();
210
211        let err = init(
212            InitOptions {
213                cwd: Some(dir.path().to_path_buf()),
214                kind: InitKind::Config,
215                path: None,
216            },
217            &mut reporter,
218        )
219        .expect_err("existing symlink should be rejected");
220
221        match err {
222            Error::InitTargetExists(path) => assert_eq!(path, link),
223            other => panic!("expected InitTargetExists, got {other:?}"),
224        }
225        assert_eq!(
226            std::fs::read_to_string(target).expect("target should be readable"),
227            "old\n"
228        );
229        assert!(
230            std::fs::symlink_metadata(link)
231                .expect("link metadata should load")
232                .file_type()
233                .is_symlink()
234        );
235        assert!(reporter.events.is_empty());
236    }
237}