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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub enum InitKind {
35 #[default]
37 Config,
38 Script,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct InitOptions {
45 pub cwd: Option<PathBuf>,
47 pub kind: InitKind,
49 pub path: Option<PathBuf>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct InitReport {
56 pub kind: InitKind,
58 pub path: PathBuf,
60}
61
62pub 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 use crate::test_support::symlink_file;
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 #[test]
200 fn init_should_refuse_existing_symlink_without_writing_through_it() {
201 let dir = TempDir::new().expect("tempdir should be created");
202 let target = dir.path().join("target.toml");
203 let link = dir.path().join(".treeboot.toml");
204 std::fs::write(&target, "old\n").expect("target should be written");
205 symlink_file(&target, &link).expect("symlink should be created");
206 let mut reporter = VecReporter::default();
207
208 let err = init(
209 InitOptions {
210 cwd: Some(dir.path().to_path_buf()),
211 kind: InitKind::Config,
212 path: None,
213 },
214 &mut reporter,
215 )
216 .expect_err("existing symlink should be rejected");
217
218 match err {
219 Error::InitTargetExists(path) => assert_eq!(path, link),
220 other => panic!("expected InitTargetExists, got {other:?}"),
221 }
222 assert_eq!(
223 std::fs::read_to_string(target).expect("target should be readable"),
224 "old\n"
225 );
226 assert!(
227 std::fs::symlink_metadata(link)
228 .expect("link metadata should load")
229 .file_type()
230 .is_symlink()
231 );
232 assert!(reporter.events.is_empty());
233 }
234}