xtask_lib/tasks/
dist.rs

1//! `cargo xtask dist` — assemble the Kobo distribution directory.
2//!
3//! Copies the compiled Cadmus binary, shared libraries, scripts, fonts,
4//! icons, and other assets into a `dist/` directory that mirrors the layout
5//! expected on the Kobo device.
6//!
7//! ## Prerequisites
8//!
9//! - `cargo xtask build-kobo` must have been run first.
10//! - `libs/` must contain the ARM shared libraries.
11//! - `bin/`, `resources/`, and `hyphenation-patterns/` must exist.
12//!
13//! ## Output layout
14//!
15//! ```text
16//! dist/
17//! ├── cadmus                  (ARM binary)
18//! ├── libs/                   (versioned .so files)
19//! ├── fonts/
20//! ├── icons/
21//! ├── css/
22//! ├── scripts/
23//! ├── keyboard-layouts/
24//! ├── hyphenation-patterns/
25//! ├── bin/
26//! ├── resources/
27//! ├── Settings-sample.toml
28//! ├── LICENSE
29//! └── *.sh                    (contrib scripts)
30//! ```
31
32use std::path::Path;
33
34use anyhow::{Context, Result, bail};
35use clap::Args;
36use wildmatch::WildMatch;
37
38use super::util::{cmd, fs, thirdparty, workspace};
39
40/// Arguments for `cargo xtask dist`.
41#[derive(Debug, Args)]
42pub struct DistArgs {
43    /// Build for the test feature set (`--features test`).
44    #[arg(long)]
45    pub test: bool,
46}
47
48/// Assembles the Kobo distribution directory.
49///
50/// # Errors
51///
52/// Returns an error if the ARM binary or any required asset is missing.
53pub fn run(args: DistArgs) -> Result<()> {
54    let root = workspace::root()?;
55
56    let binary = root.join("target/arm-unknown-linux-gnueabihf/release/cadmus");
57    if !binary.exists() {
58        bail!(
59            "ARM binary not found at {}.\n\
60             Run `cargo xtask build-kobo` first.",
61            binary.display()
62        );
63    }
64
65    let dist_dir = root.join("dist");
66    if dist_dir.exists() {
67        std::fs::remove_dir_all(&dist_dir).context("failed to remove existing dist/")?;
68    }
69    std::fs::create_dir_all(&dist_dir)?;
70    std::fs::create_dir_all(dist_dir.join("libs"))?;
71    std::fs::create_dir_all(dist_dir.join("dictionaries"))?;
72
73    copy_libraries(&root, &dist_dir)?;
74    copy_assets(&root, &dist_dir)?;
75    copy_binary(&root, &dist_dir)?;
76    strip_and_patch(&root, &dist_dir)?;
77    clean_user_files(&dist_dir)?;
78
79    if args.test {
80        println!("Test build assembled in dist/");
81    } else {
82        println!("Distribution assembled in dist/");
83    }
84
85    Ok(())
86}
87
88/// Copies ARM shared libraries from `libs/` into `dist/libs/` with versioned
89/// names expected by the Kobo runtime linker.
90fn copy_libraries(root: &Path, dist_dir: &Path) -> Result<()> {
91    let libs_dir = root.join("libs");
92    let dist_libs = dist_dir.join("libs");
93
94    for &lib in thirdparty::SONAMES {
95        let soname = thirdparty::soname(&libs_dir, lib)?;
96        let src = libs_dir.join(lib);
97        let dest = dist_libs.join(&soname);
98        std::fs::copy(&src, &dest).with_context(|| {
99            format!(
100                "failed to copy {} → {}\n\
101                 Run `cargo xtask build-kobo` to build the libraries.",
102                src.display(),
103                dest.display()
104            )
105        })?;
106    }
107
108    Ok(())
109}
110
111/// Copies static assets (fonts, icons, scripts, etc.) into `dist/`.
112fn copy_assets(root: &Path, dist_dir: &Path) -> Result<()> {
113    let dirs = [
114        "hyphenation-patterns",
115        "keyboard-layouts",
116        "bin",
117        "scripts",
118        "icons",
119        "resources",
120        "fonts",
121        "css",
122    ];
123
124    for dir in dirs {
125        let src = root.join(dir);
126        if !src.exists() {
127            bail!(
128                "Required asset directory '{}' not found.\n\
129                 Run `cargo xtask download-assets` to download it.",
130                src.display()
131            );
132        }
133        fs::copy_dir_all(&src, &dist_dir.join(dir))?;
134    }
135
136    // Contrib scripts and sample config
137    for entry in std::fs::read_dir(root.join("contrib"))? {
138        let entry = entry?;
139        let path = entry.path();
140        if path.extension().is_some_and(|e| e == "sh") {
141            std::fs::copy(&path, dist_dir.join(entry.file_name()))?;
142        }
143    }
144
145    std::fs::copy(
146        root.join("contrib/Settings-sample.toml"),
147        dist_dir.join("Settings-sample.toml"),
148    )?;
149
150    std::fs::copy(root.join("LICENSE"), dist_dir.join("LICENSE"))?;
151
152    Ok(())
153}
154
155/// Copies the compiled ARM binary into `dist/`.
156fn copy_binary(root: &Path, dist_dir: &Path) -> Result<()> {
157    std::fs::copy(
158        root.join("target/arm-unknown-linux-gnueabihf/release/cadmus"),
159        dist_dir.join("cadmus"),
160    )
161    .context("failed to copy cadmus binary")?;
162    Ok(())
163}
164
165/// Strips debug symbols and removes RPATH from the binary and all libraries.
166///
167/// RPATH is removed so the libraries resolve against the device's default
168/// linker search paths rather than the build host's paths.
169fn strip_and_patch(root: &Path, dist_dir: &Path) -> Result<()> {
170    let libs_dir = dist_dir.join("libs");
171    for entry in std::fs::read_dir(&libs_dir)? {
172        let path = entry?.path();
173        cmd::run(
174            "patchelf",
175            &["--remove-rpath", &path.to_string_lossy()],
176            root,
177            &[],
178        )?;
179    }
180
181    // Strip the binary and all libraries to reduce size.
182    let binary = dist_dir.join("cadmus");
183    let mut strip_targets = vec![binary.to_string_lossy().into_owned()];
184    for entry in std::fs::read_dir(&libs_dir)? {
185        strip_targets.push(entry?.path().to_string_lossy().into_owned());
186    }
187
188    let strip_refs: Vec<&str> = strip_targets.iter().map(String::as_str).collect();
189    cmd::run("arm-linux-gnueabihf-strip", &strip_refs, root, &[])
190}
191
192/// Removes user-specific files that should not be distributed.
193fn clean_user_files(dist_dir: &Path) -> Result<()> {
194    let patterns: &[(&str, &str)] = &[
195        ("css", "*-user.css"),
196        ("keyboard-layouts", "*-user.json"),
197        ("hyphenation-patterns", "*.bounds"),
198        ("scripts", "wifi-*-*.sh"),
199    ];
200
201    for (subdir, pattern) in patterns {
202        let dir = dist_dir.join(subdir);
203        if dir.exists() {
204            remove_matching(&dir, pattern)?;
205        }
206    }
207
208    Ok(())
209}
210
211/// Removes files in `dir` whose names match the glob `pattern`.
212fn remove_matching(dir: &Path, pattern: &str) -> Result<()> {
213    let matcher = WildMatch::new(pattern);
214
215    for entry in std::fs::read_dir(dir)? {
216        let path = entry?.path();
217        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
218            if matcher.matches(name) {
219                std::fs::remove_file(&path)
220                    .with_context(|| format!("failed to remove {}", path.display()))?;
221            }
222        }
223    }
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::fs;
231
232    #[test]
233    fn remove_matching_deletes_only_matching_entries() {
234        let tmp = tempfile::tempdir().unwrap();
235        let dir = tmp.path();
236
237        fs::write(dir.join("default-user.css"), b"x").unwrap();
238        fs::write(dir.join("default.css"), b"x").unwrap();
239        fs::write(dir.join("wifi-enable-eth0.sh"), b"x").unwrap();
240        fs::write(dir.join("wifi-enable.sh"), b"x").unwrap();
241
242        remove_matching(dir, "*-user.css").unwrap();
243        remove_matching(dir, "wifi-*-*.sh").unwrap();
244
245        assert!(!dir.join("default-user.css").exists());
246        assert!(dir.join("default.css").exists());
247        assert!(!dir.join("wifi-enable-eth0.sh").exists());
248        assert!(dir.join("wifi-enable.sh").exists());
249    }
250}