Skip to main content

qli_ext/
defaults.rs

1//! Embedded extension defaults.
2//!
3//! The repo's `extensions/` tree is compiled into the binary at build time
4//! via [`include_dir!`]. Two write paths consume it:
5//!
6//! - **Dispatch-time materialization** — on every startup the binary
7//!   extracts [`DEFAULTS`] to a version-keyed cache root
8//!   (`$XDG_CACHE_HOME/qli/embedded/<version>/`). Discovery then walks
9//!   both that cache root and `$XDG_DATA_HOME/qli/extensions/`, with the
10//!   user-editable XDG copy shadowing embedded per-group. Net: a freshly
11//!   installed binary has working defaults with no user opt-in.
12//! - **`qli ext install-defaults`** — explicit user opt-in; copies
13//!   [`DEFAULTS`] into `$XDG_DATA_HOME/qli/extensions/` so the user can
14//!   edit them. Same [`materialize_to`] entry point, different target
15//!   root, optional `--force` overwrite.
16//!
17//! `include_dir` does not preserve mode bits, so [`materialize_to`]
18//! explicitly chmods every non-`_manifest.toml` file to `0o755` on Unix
19//! after writing it. Without that, discovery's `is_executable` filter
20//! would warn-and-skip every shipped script.
21//!
22//! ## Crate-publish (resolved in Phase 1.5C via symlink)
23//!
24//! [`include_dir!`] resolves the path at compile time relative to
25//! `$CARGO_MANIFEST_DIR`, and `cargo publish` strips files outside the
26//! crate directory — so a path like `../../extensions` would compile
27//! locally but produce an empty [`DEFAULTS`] in the published tarball.
28//! Resolution: `crates/qli-ext/extensions` is a symlink to the
29//! workspace-root `extensions/` directory. `cargo package` dereferences
30//! the symlink and bundles the actual files into the crate tarball, so
31//! the published `qli-ext` is self-contained while the workspace root
32//! stays the canonical edit location.
33
34use std::fs;
35use std::io;
36use std::path::{Path, PathBuf};
37
38use include_dir::{include_dir, Dir, DirEntry};
39use thiserror::Error;
40
41/// Compile-time snapshot of the repo's `extensions/` tree.
42pub static DEFAULTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/extensions");
43
44/// Counters from a [`materialize_to`] run. Useful for log lines and
45/// `install-defaults` output ("wrote N files, skipped M existing").
46#[derive(Debug, Default, Clone, Copy)]
47pub struct MaterializeStats {
48    pub written: usize,
49    pub skipped: usize,
50}
51
52/// Errors raised while writing the embedded tree to disk.
53#[derive(Debug, Error)]
54pub enum MaterializeError {
55    #[error("could not create directory {path:?}: {source}")]
56    CreateDir {
57        path: PathBuf,
58        #[source]
59        source: io::Error,
60    },
61    #[error("could not write {path:?}: {source}")]
62    Write {
63        path: PathBuf,
64        #[source]
65        source: io::Error,
66    },
67    #[error("could not chmod {path:?}: {source}")]
68    Chmod {
69        path: PathBuf,
70        #[source]
71        source: io::Error,
72    },
73}
74
75/// Write [`DEFAULTS`] into `target_root`, preserving the
76/// `<group>/<file>` layout. Files whose name is not `_manifest.toml` are
77/// chmod'd to `0o755` on Unix (so discovery treats them as executable).
78///
79/// Idempotent: existing files are skipped unless `force = true`. Skipping
80/// is observed at the file granularity, not the group — a partially
81/// installed group remains partially installed unless `force` is passed.
82pub fn materialize_to(
83    target_root: &Path,
84    force: bool,
85) -> Result<MaterializeStats, MaterializeError> {
86    let mut stats = MaterializeStats::default();
87    // Top-level files (e.g. the repo's `extensions/README.md`) are
88    // documentation, not extensions — skip them. Only descend into
89    // subdirectories: each one is a group.
90    for entry in DEFAULTS.entries() {
91        if let DirEntry::Dir(sub) = entry {
92            let sub_target = target_root.join(sub.path());
93            fs::create_dir_all(&sub_target).map_err(|source| MaterializeError::CreateDir {
94                path: sub_target.clone(),
95                source,
96            })?;
97            materialize_dir(sub, target_root, force, &mut stats)?;
98        }
99    }
100    Ok(stats)
101}
102
103fn materialize_dir(
104    dir: &Dir<'_>,
105    target_root: &Path,
106    force: bool,
107    stats: &mut MaterializeStats,
108) -> Result<(), MaterializeError> {
109    for entry in dir.entries() {
110        match entry {
111            DirEntry::Dir(sub) => {
112                let sub_target = target_root.join(sub.path());
113                fs::create_dir_all(&sub_target).map_err(|source| MaterializeError::CreateDir {
114                    path: sub_target.clone(),
115                    source,
116                })?;
117                materialize_dir(sub, target_root, force, stats)?;
118            }
119            DirEntry::File(file) => {
120                let dest = target_root.join(file.path());
121                if let Some(parent) = dest.parent() {
122                    fs::create_dir_all(parent).map_err(|source| MaterializeError::CreateDir {
123                        path: parent.to_path_buf(),
124                        source,
125                    })?;
126                }
127                if dest.exists() && !force {
128                    stats.skipped += 1;
129                    continue;
130                }
131                fs::write(&dest, file.contents()).map_err(|source| MaterializeError::Write {
132                    path: dest.clone(),
133                    source,
134                })?;
135                let is_manifest = dest
136                    .file_name()
137                    .and_then(|s| s.to_str())
138                    .is_some_and(|name| name == "_manifest.toml");
139                if !is_manifest {
140                    set_executable(&dest)?;
141                }
142                stats.written += 1;
143            }
144        }
145    }
146    Ok(())
147}
148
149#[cfg(unix)]
150fn set_executable(path: &Path) -> Result<(), MaterializeError> {
151    use std::os::unix::fs::PermissionsExt;
152    let perms = fs::Permissions::from_mode(0o755);
153    fs::set_permissions(path, perms).map_err(|source| MaterializeError::Chmod {
154        path: path.to_path_buf(),
155        source,
156    })
157}
158
159#[cfg(not(unix))]
160fn set_executable(_path: &Path) -> Result<(), MaterializeError> {
161    // On non-Unix, executability isn't a permission bit; discovery's
162    // `is_executable` returns true for any regular file there. Nothing
163    // to do.
164    Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn defaults_contains_expected_groups() {
173        // If this fails, the include_dir! path probably regressed.
174        let dirs: Vec<_> = DEFAULTS
175            .entries()
176            .iter()
177            .filter_map(|e| match e {
178                DirEntry::Dir(d) => d.path().file_name().and_then(|s| s.to_str()),
179                DirEntry::File(_) => None,
180            })
181            .collect();
182        for expected in &["dev", "prod", "org"] {
183            assert!(
184                dirs.contains(expected),
185                "expected `{expected}` group in DEFAULTS, got {dirs:?}",
186            );
187        }
188    }
189
190    #[test]
191    fn materialize_writes_manifests_and_scripts() {
192        let tmp = tempfile::tempdir().unwrap();
193        let stats = materialize_to(tmp.path(), false).unwrap();
194        assert!(
195            stats.written >= 6,
196            "expected ≥6 files, got {}",
197            stats.written
198        );
199        assert_eq!(stats.skipped, 0);
200
201        for group in &["dev", "prod", "org"] {
202            let manifest = tmp.path().join(group).join("_manifest.toml");
203            assert!(
204                manifest.exists(),
205                "manifest missing for {group}: {}",
206                manifest.display(),
207            );
208            let script = tmp.path().join(group).join("hello");
209            assert!(
210                script.exists(),
211                "script missing for {group}: {}",
212                script.display(),
213            );
214        }
215    }
216
217    #[test]
218    #[cfg(unix)]
219    fn materialize_sets_exec_bit_on_scripts_only() {
220        use std::os::unix::fs::PermissionsExt;
221        let tmp = tempfile::tempdir().unwrap();
222        materialize_to(tmp.path(), false).unwrap();
223
224        let script_mode = fs::metadata(tmp.path().join("dev/hello"))
225            .unwrap()
226            .permissions()
227            .mode();
228        assert_eq!(
229            script_mode & 0o777,
230            0o755,
231            "hello script should be 0o755, got {script_mode:o}",
232        );
233
234        // Manifest should NOT have exec bits — discovery's `_*` skip
235        // would never see it anyway, but the principle stands: only
236        // files we'd dispatch are executable.
237        let manifest_mode = fs::metadata(tmp.path().join("dev/_manifest.toml"))
238            .unwrap()
239            .permissions()
240            .mode();
241        assert_eq!(
242            manifest_mode & 0o111,
243            0,
244            "manifest should not be executable, got {manifest_mode:o}",
245        );
246    }
247
248    #[test]
249    fn materialize_is_idempotent_without_force() {
250        let tmp = tempfile::tempdir().unwrap();
251        let first = materialize_to(tmp.path(), false).unwrap();
252        let second = materialize_to(tmp.path(), false).unwrap();
253        assert!(first.written >= 6);
254        assert_eq!(second.written, 0, "second run should write nothing");
255        assert_eq!(second.skipped, first.written);
256    }
257
258    #[test]
259    fn materialize_skips_top_level_files() {
260        // The repo's `extensions/README.md` is documentation, not an
261        // extension. It must not be installed into the user's XDG dir.
262        let tmp = tempfile::tempdir().unwrap();
263        materialize_to(tmp.path(), false).unwrap();
264        assert!(
265            !tmp.path().join("README.md").exists(),
266            "top-level README.md must not be materialized",
267        );
268    }
269
270    #[test]
271    fn materialize_force_overwrites_existing_files() {
272        let tmp = tempfile::tempdir().unwrap();
273        materialize_to(tmp.path(), false).unwrap();
274        let target = tmp.path().join("dev/hello");
275        fs::write(&target, "edited by user\n").unwrap();
276        let stats = materialize_to(tmp.path(), true).unwrap();
277        assert_eq!(stats.skipped, 0);
278        assert!(stats.written >= 6);
279        let body = fs::read_to_string(&target).unwrap();
280        assert!(
281            !body.contains("edited by user"),
282            "force should overwrite, got: {body}",
283        );
284    }
285}