Skip to main content
← dvs documentation Rust API reference

dvs/
init.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, bail};
4use fs_err as fs;
5use uuid::Uuid;
6
7use crate::audit::AuditEntry;
8use crate::backends::Backend;
9use crate::config::Config;
10use crate::paths;
11
12/// Creates the metadata folder of the project in `root_dir`, if it doesn't exist yet.
13/// Both `init` and `attach` need it: the commands that track files expect it.
14pub fn create_metadata_folder(root_dir: impl AsRef<Path>, config: &Config) -> Result<()> {
15    let metadata_dir = root_dir.as_ref().join(config.metadata_folder_name());
16    log::debug!("Creating metadata folder: {}", metadata_dir.display());
17    if let Err(e) = fs::create_dir(&metadata_dir)
18        && e.kind() != std::io::ErrorKind::AlreadyExists
19    {
20        return Err(e.into());
21    }
22    Ok(())
23}
24
25/// Starts a new dvs project.
26/// We need a ready to use Config object + the current directory the user is in
27pub fn init(root_dir: impl AsRef<Path>, mut config: Config) -> Result<PathBuf> {
28    let root_dir = root_dir.as_ref();
29
30    // The storage directory must not live inside the repository, otherwise it
31    // would be tracked alongside the user's files.
32    match &mut config.backend {
33        Backend::Local(b) => {
34            let storage_path = &b.path;
35            let abs_storage = if storage_path.exists() {
36                fs::canonicalize(storage_path)?
37            } else if let Some(parent) = storage_path.parent().filter(|p| p.exists()) {
38                fs::canonicalize(parent)?.join(storage_path.file_name().unwrap())
39            } else {
40                std::path::absolute(storage_path)?
41            };
42            let abs_root = fs::canonicalize(root_dir)?;
43            if abs_storage.starts_with(&abs_root) {
44                bail!("The given storage path is within the repository.");
45            }
46            b.path = abs_storage;
47        }
48        Backend::Server(_) => { /* nothing to do here */ }
49    }
50
51    if root_dir.join(paths::CONFIG_FILE_NAME).exists() {
52        bail!("dvs is already initialized (dvs.toml exists)");
53    }
54
55    // Check the metadata folder before touching the backend
56    let metadata_dir = root_dir.join(config.metadata_folder_name());
57    if metadata_dir.exists() && fs::read_dir(&metadata_dir)?.next().is_some() {
58        bail!(
59            "dvs is already initialized ({} exists and is not empty)",
60            config.metadata_folder_name()
61        );
62    }
63
64    log::debug!("Initializing backend");
65    if config.backend().init(config.compression())? {
66        if let Backend::Server(s) = config.backend() {
67            bail!("the project {} already exists on {}", s.name, s.url);
68        }
69        bail!("dvs is already initialized (backend storage exists)");
70    }
71
72    // The server decides the compression, so there is nothing to keep past init
73    if matches!(config.backend(), Backend::Server(_)) {
74        config.set_compression(None);
75    }
76
77    config.save(root_dir)?;
78
79    let config_path = root_dir.join(paths::CONFIG_FILE_NAME);
80
81    if let Err(e) = create_metadata_folder(root_dir, &config) {
82        // Roll back the config file we just wrote.
83        let _ = fs::remove_file(&config_path);
84        return Err(e);
85    }
86
87    match &config.backend {
88        Backend::Local(b) => {
89            let audit_entry =
90                AuditEntry::new_init(Uuid::new_v4(), config.clone(), root_dir.to_path_buf());
91            if let Err(e) = b.log_audit_entry(&audit_entry) {
92                log::error!("Failed to write init audit log {audit_entry:?}: {e}");
93            }
94        }
95        Backend::Server(_) => { /* noop */ }
96    }
97
98    log::info!("DVS repository initialized successfully");
99    Ok(root_dir.to_path_buf())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::testutil::create_temp_git_repo;
106
107    #[test]
108    fn init_creates_config_and_directories() {
109        let (_tmp, root) = create_temp_git_repo();
110        let storage = root.parent().unwrap().join(".storage");
111
112        let config = Config::new_local(&storage, None).unwrap();
113        init(&root, config).unwrap();
114
115        // Config file should exist
116        assert!(root.join("dvs.toml").is_file());
117        // Metadata folder should exist
118        assert!(root.join(".dvs").is_dir());
119        // Storage folder should exist
120        assert!(storage.is_dir());
121    }
122
123    #[test]
124    fn init_logs_audit_entry() {
125        let (_tmp, root) = create_temp_git_repo();
126        let storage = root.parent().unwrap().join(".storage");
127
128        let config = Config::new_local(&storage, None).unwrap();
129        init(&root, config.clone()).unwrap();
130
131        let entries = config.backend().get_audit_entries(&[]).unwrap();
132        assert_eq!(entries.len(), 1);
133        assert!(matches!(
134            entries[0].action,
135            crate::audit::Action::Init { .. }
136        ));
137    }
138
139    #[test]
140    fn init_fails_if_already_initialized() {
141        let (_tmp, root) = create_temp_git_repo();
142        let storage = root.parent().unwrap().join(".storage");
143
144        let config = Config::new_local(&storage, None).unwrap();
145        init(&root, config.clone()).unwrap();
146
147        // Second init should fail because dvs.toml exists
148        let result = init(&root, config.clone());
149        assert!(result.is_err(), "second init should fail");
150    }
151
152    #[test]
153    fn init_fails_if_backend_already_initialized() {
154        let (_tmp, root) = create_temp_git_repo();
155        let storage = root.parent().unwrap().join(".storage");
156
157        let config = Config::new_local(&storage, None).unwrap();
158        init(&root, config.clone()).unwrap();
159
160        // Remove dvs.toml and .dvs but leave backend storage intact
161        fs::remove_file(root.join("dvs.toml")).unwrap();
162        fs::remove_dir_all(root.join(".dvs")).unwrap();
163
164        let result = init(&root, config);
165        assert!(
166            result.is_err(),
167            "should detect backend is already initialized"
168        );
169    }
170
171    #[test]
172    fn init_rejects_storage_inside_repo() {
173        let (_tmp, root) = create_temp_git_repo();
174        // Storage directory nested inside the repository root.
175        let storage = root.join("inside").join(".storage");
176
177        let config = Config::new_local(&storage, None).unwrap();
178        let result = init(&root, config);
179        assert!(
180            result.is_err(),
181            "init should reject storage inside the repository"
182        );
183        assert!(
184            result
185                .unwrap_err()
186                .to_string()
187                .contains("within the repository"),
188            "error should explain the storage path is inside the repo"
189        );
190        // Nothing should have been created.
191        assert!(!root.join("dvs.toml").exists());
192    }
193
194    #[test]
195    fn init_succeeds_in_subdirectory_of_initialized_project() {
196        let (_tmp, root) = create_temp_git_repo();
197        let storage = root.parent().unwrap().join(".storage");
198
199        // Initialize the parent project
200        let config = Config::new_local(&storage, None).unwrap();
201        init(&root, config).unwrap();
202
203        // Create a subdirectory and initialize a nested project there
204        let subdir = root.join("nested");
205        fs::create_dir(&subdir).unwrap();
206        let nested_storage = root.parent().unwrap().join(".nested-storage");
207        let nested_config = Config::new_local(&nested_storage, None).unwrap();
208        let result = init(&subdir, nested_config);
209        assert!(
210            result.is_ok(),
211            "init in subdirectory should succeed: {result:?}"
212        );
213        assert!(subdir.join("dvs.toml").is_file());
214        assert!(subdir.join(".dvs").is_dir());
215    }
216
217    #[cfg(unix)]
218    #[test]
219    fn init_cleans_up_on_backend_failure() {
220        let (_tmp, root) = create_temp_git_repo();
221        // Point storage at an impossible path so backend init fails
222        let storage = Path::new("/dev/null/impossible");
223
224        let config = Config::new_local(storage, None).unwrap();
225        let result = init(&root, config);
226        assert!(result.is_err());
227
228        // Local artifacts should have been cleaned up
229        assert!(
230            !root.join("dvs.toml").exists(),
231            "dvs.toml should be cleaned up"
232        );
233        assert!(!root.join(".dvs").exists(), ".dvs should be cleaned up");
234
235        // A retry with a valid storage path should now succeed
236        let valid_storage = root.parent().unwrap().join(".storage");
237        let config = Config::new_local(&valid_storage, None).unwrap();
238        init(&root, config).unwrap();
239        assert!(root.join("dvs.toml").is_file());
240        assert!(root.join(".dvs").is_dir());
241    }
242
243    #[test]
244    fn init_preserves_preexisting_metadata_dir_on_failure() {
245        let (_tmp, root) = create_temp_git_repo();
246        // Create a .dvs directory before init runs
247        let metadata_dir = root.join(".dvs");
248        fs::create_dir(&metadata_dir).unwrap();
249        let marker = metadata_dir.join("marker.txt");
250        fs::write(&marker, "keep me").unwrap();
251
252        let storage = Path::new("/dev/null/impossible");
253        let config = Config::new_local(storage, None).unwrap();
254        let result = init(&root, config);
255        assert!(result.is_err());
256
257        // dvs.toml should be cleaned up
258        assert!(
259            !root.join("dvs.toml").exists(),
260            "dvs.toml should be cleaned up"
261        );
262        // Pre-existing .dvs directory and its contents should be preserved
263        assert!(metadata_dir.is_dir(), ".dvs should be preserved");
264        assert!(
265            marker.is_file(),
266            "marker file inside .dvs should be preserved"
267        );
268    }
269}