Skip to main content

miku/
init.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5/// Where to set up the workspace.
6#[derive(Debug, Clone, Copy)]
7pub enum InitTarget {
8    /// XDG: a single `$XDG_DATA_HOME/miku/` root (default
9    /// `~/.local/share/miku/`). Default for globally installed users — no
10    /// project-local files written.
11    Xdg,
12    /// Project-local: `<cwd>/.miku/{data,topics,config}/` + `miku.toml`.
13    Local,
14}
15
16pub struct InitReport {
17    pub target: InitTarget,
18    pub created_dirs: Vec<PathBuf>,
19    pub skipped_dirs: Vec<PathBuf>,
20    pub wrote_config: Option<PathBuf>,
21    pub config_existed: Option<PathBuf>,
22}
23
24/// Initialise a Miku workspace.
25///
26/// `InitTarget::Xdg` creates the user-level `{data,config,topics}` directories
27/// under a single `$XDG_DATA_HOME/miku/` root (default
28/// `~/.local/share/miku/`, honoring `XDG_DATA_HOME` on every platform),
29/// all owned by the invoking user.
30///
31/// `InitTarget::Local` writes a `miku.toml` and `.miku/` tree into
32/// `cwd`. Re-runs are idempotent in both modes.
33pub fn init_workspace(target: InitTarget, cwd: &Path) -> Result<InitReport> {
34    match target {
35        InitTarget::Xdg => init_xdg(),
36        InitTarget::Local => init_local(cwd),
37    }
38}
39
40fn init_xdg() -> Result<InitReport> {
41    let xdg = crate::paths::xdg_dirs().ok_or_else(|| {
42        anyhow::anyhow!("XDG paths unavailable ($HOME unset); retry with `--local`")
43    })?;
44    let dirs = [xdg.data_dir, xdg.topics_dir, xdg.config_dir];
45    let (created, skipped) = ensure_dirs(&dirs)?;
46    Ok(InitReport {
47        target: InitTarget::Xdg,
48        created_dirs: created,
49        skipped_dirs: skipped,
50        wrote_config: None,
51        config_existed: None,
52    })
53}
54
55fn init_local(cwd: &Path) -> Result<InitReport> {
56    let base = cwd.join(".miku");
57    let dirs = [base.join("data"), base.join("topics"), base.join("config")];
58    let (created, skipped) = ensure_dirs(&dirs)?;
59
60    let config_path = cwd.join("miku.toml");
61    let (wrote, existed) = if config_path.exists() {
62        (None, Some(config_path))
63    } else {
64        fs::write(&config_path, DEFAULT_LOCAL_CONFIG)
65            .with_context(|| format!("write {}", config_path.display()))?;
66        (Some(config_path), None)
67    };
68
69    Ok(InitReport {
70        target: InitTarget::Local,
71        created_dirs: created,
72        skipped_dirs: skipped,
73        wrote_config: wrote,
74        config_existed: existed,
75    })
76}
77
78fn ensure_dirs(dirs: &[PathBuf]) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
79    let mut created = Vec::new();
80    let mut skipped = Vec::new();
81    for dir in dirs {
82        if dir.exists() {
83            skipped.push(dir.clone());
84        } else {
85            fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
86            created.push(dir.clone());
87        }
88    }
89    Ok((created, skipped))
90}
91
92const DEFAULT_LOCAL_CONFIG: &str = "\
93# Miku project-local configuration.
94# Paths are resolved relative to this file.
95
96data_dir   = \".miku/data\"
97config_dir = \".miku/config\"
98topics_dir = \".miku/topics\"
99";
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use tempfile::tempdir;
105
106    #[test]
107    fn local_creates_dirs_and_config_first_time() {
108        let dir = tempdir().unwrap();
109        let report = init_workspace(InitTarget::Local, dir.path()).unwrap();
110
111        assert!(report.wrote_config.is_some());
112        assert_eq!(report.created_dirs.len(), 3);
113        assert!(report.skipped_dirs.is_empty());
114        assert!(dir.path().join("miku.toml").exists());
115        assert!(dir.path().join(".miku/data").is_dir());
116        assert!(dir.path().join(".miku/topics").is_dir());
117        assert!(dir.path().join(".miku/config").is_dir());
118    }
119
120    #[test]
121    fn local_idempotent_second_run() {
122        let dir = tempdir().unwrap();
123        init_workspace(InitTarget::Local, dir.path()).unwrap();
124        let report = init_workspace(InitTarget::Local, dir.path()).unwrap();
125
126        assert!(report.wrote_config.is_none());
127        assert!(report.config_existed.is_some());
128        assert!(report.created_dirs.is_empty());
129        assert_eq!(report.skipped_dirs.len(), 3);
130    }
131
132    #[test]
133    fn xdg_creates_unified_root_under_xdg_data_home() {
134        let dir = tempdir().unwrap();
135        let data = dir.path().join("xdg-data");
136
137        let saved = std::env::var("XDG_DATA_HOME").ok();
138        unsafe {
139            std::env::set_var("XDG_DATA_HOME", &data);
140        }
141
142        let report = init_workspace(InitTarget::Xdg, dir.path()).unwrap();
143
144        match saved {
145            Some(val) => unsafe { std::env::set_var("XDG_DATA_HOME", val) },
146            None => unsafe { std::env::remove_var("XDG_DATA_HOME") },
147        }
148
149        assert!(matches!(report.target, InitTarget::Xdg));
150        let root = data.join("miku");
151        assert!(root.join("data").is_dir());
152        assert!(root.join("topics").is_dir());
153        assert!(root.join("config").is_dir());
154    }
155
156    #[test]
157    fn local_preserves_existing_config() {
158        let dir = tempdir().unwrap();
159        let cfg = dir.path().join("miku.toml");
160        fs::write(&cfg, "# user-customised\n").unwrap();
161
162        init_workspace(InitTarget::Local, dir.path()).unwrap();
163        assert_eq!(fs::read_to_string(&cfg).unwrap(), "# user-customised\n");
164    }
165}