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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum InitKind {
36 #[default]
38 Config,
39 Script,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct InitOptions {
46 pub cwd: Option<PathBuf>,
48 pub kind: InitKind,
50 pub path: Option<PathBuf>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct InitReport {
57 pub kind: InitKind,
59 pub path: PathBuf,
61}
62
63pub 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}