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