Skip to main content

omne_cli/commands/
init.rs

1//! `omne init <distro>` — scaffold a new omne volume.
2//!
3//! Orchestrates: distro spec parse → `.omne/` precheck → scaffold →
4//! tarball extraction → manifest stamp → bootloader write.
5//!
6//! Three entry points:
7//! - `run()` — CLI handler, uses real GitHub API
8//! - `init_with_client()` — test seam for HTTP integration tests (mockito)
9//! - `init_with_tarballs()` — test seam for offline fixture tests
10
11// Test-seam functions are called from lib.rs (integration tests) but
12// not from main.rs (binary). The bin crate sees them as dead code.
13#![allow(dead_code)]
14
15use std::fs;
16use std::path::Path;
17
18use clap::Args as ClapArgs;
19
20use crate::defaults;
21use crate::distro;
22use crate::error::CliError;
23use crate::fetch;
24use crate::github::GithubClient;
25use crate::manifest;
26use crate::scaffold;
27use crate::tarball;
28
29/// Arguments for `omne init`.
30#[derive(Debug, ClapArgs)]
31pub struct Args {
32    /// Distro specifier (run `omne init --help` for accepted forms).
33    #[arg(long_help = "Distro specifier.\n\
34\n\
35Accepted forms:\n\
36  bare name:  omne-nosce\n\
37  org/repo:   omne-org/omne-nosce\n\
38  HTTPS URL:  https://github.com/omne-org/omne-nosce.git\n\
39  SSH URL:    git@github.com:omne-org/omne-nosce.git\n\
40\n\
41file:// URLs and non-github.com hosts are rejected.")]
42    pub distro: String,
43}
44
45pub fn run(args: &Args) -> Result<(), CliError> {
46    let root = std::env::current_dir()
47        .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
48    let github = GithubClient::from_env("https://api.github.com", "omne-cli");
49    init_with_client(&args.distro, &root, &github)
50}
51
52/// Init with a pre-configured `GithubClient` — test seam for mockito tests.
53pub fn init_with_client(
54    distro_spec: &str,
55    root: &Path,
56    github: &GithubClient,
57) -> Result<(), CliError> {
58    let spec = distro::parse(distro_spec)?;
59
60    let omne = root.join(".omne");
61    if omne.exists() {
62        return Err(CliError::VolumeAlreadyExists { path: omne });
63    }
64
65    scaffold::create_volume_dirs(root)?;
66
67    // Fetch kernel
68    let (kernel_org, kernel_repo) = parse_source(defaults::DEFAULT_KERNEL_SOURCE);
69    let kernel_tag = github.latest_release_tag(kernel_org, kernel_repo)?;
70    fetch::download_and_extract(github, kernel_org, kernel_repo, &kernel_tag, &omne, "core")?;
71
72    // Fetch distro
73    let distro_tag = github.latest_release_tag(&spec.org, &spec.repo)?;
74    fetch::download_and_extract(github, &spec.org, &spec.repo, &distro_tag, &omne, "image")?;
75
76    // Stamp and finalize
77    stamp_and_finalize(root, &omne, &spec)
78}
79
80/// Init with pre-built tarballs — test seam for offline fixture tests.
81pub fn init_with_tarballs(
82    distro_spec: &str,
83    root: &Path,
84    kernel_tarball: &Path,
85    distro_tarball: &Path,
86) -> Result<(), CliError> {
87    let spec = distro::parse(distro_spec)?;
88
89    let omne = root.join(".omne");
90    if omne.exists() {
91        return Err(CliError::VolumeAlreadyExists { path: omne });
92    }
93
94    scaffold::create_volume_dirs(root)?;
95
96    let kernel_file = fs::File::open(kernel_tarball)?;
97    tarball::extract_safe(kernel_file, &omne)?;
98    verify_top_level(&omne, "core")?;
99
100    let distro_file = fs::File::open(distro_tarball)?;
101    tarball::extract_safe(distro_file, &omne)?;
102    verify_top_level(&omne, "image")?;
103
104    stamp_and_finalize(root, &omne, &spec)
105}
106
107/// Shared post-extraction logic: read distro metadata, stamp manifest,
108/// write bootloader, print success.
109fn stamp_and_finalize(root: &Path, omne: &Path, spec: &distro::DistroSpec) -> Result<(), CliError> {
110    let (distro_name, distro_version) = read_distro_metadata(&omne.join("image"));
111    let volume_name = root
112        .file_name()
113        .map(|n| n.to_string_lossy().into_owned())
114        .unwrap_or_else(|| "unknown".to_string());
115
116    let today = chrono_today();
117    let vars = manifest::Vars {
118        volume: volume_name,
119        distro: distro_name.clone(),
120        distro_version,
121        created: today,
122        kernel_source: defaults::DEFAULT_KERNEL_SOURCE.to_string(),
123        distro_source: format!("{}/{}", spec.org, spec.repo),
124    };
125    let stamped = manifest::stamp(&vars);
126    scaffold::write_manifest(root, &stamped)?;
127    scaffold::write_bootloader(root)?;
128
129    eprintln!(
130        "\x1b[32m✓\x1b[0m Initialized omne volume '{}' with distro '{}'",
131        vars.volume, distro_name
132    );
133
134    Ok(())
135}
136
137/// Split a `"org/repo"` source string into `(&str, &str)`.
138fn parse_source(source: &str) -> (&str, &str) {
139    let (org, repo) = source.split_once('/').expect("source must be org/repo");
140    (org, repo)
141}
142
143/// Read distro name and version from `image/manifest.json`.
144/// Returns `("unknown", "0.0.0")` when the file is missing or malformed.
145fn read_distro_metadata(image_dir: &Path) -> (String, String) {
146    let manifest_path = image_dir.join("manifest.json");
147    if manifest_path.is_file() {
148        if let Ok(content) = fs::read_to_string(&manifest_path) {
149            if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
150                let name = data
151                    .get("name")
152                    .and_then(|v| v.as_str())
153                    .unwrap_or("unknown")
154                    .to_string();
155                let version = data
156                    .get("version")
157                    .and_then(|v| v.as_str())
158                    .unwrap_or("0.0.0")
159                    .to_string();
160                return (name, version);
161            }
162        }
163    }
164    ("unknown".to_string(), "0.0.0".to_string())
165}
166
167/// Verify that extraction produced the expected top-level directory.
168/// Mirrors the check in `fetch::download_and_extract` so the offline
169/// test seam (`init_with_tarballs`) has the same invariant.
170fn verify_top_level(target: &Path, expected: &str) -> Result<(), CliError> {
171    if !target.join(expected).is_dir() {
172        let found: Vec<String> = fs::read_dir(target)
173            .ok()
174            .map(|entries| {
175                entries
176                    .filter_map(|e| e.ok())
177                    .map(|e| e.file_name().to_string_lossy().into_owned())
178                    .collect()
179            })
180            .unwrap_or_default();
181        return Err(CliError::TarballLayoutMismatch {
182            expected: expected.to_string(),
183            found,
184        });
185    }
186    Ok(())
187}
188
189/// Today's date as `YYYY-MM-DD`.
190fn chrono_today() -> String {
191    let now = std::time::SystemTime::now();
192    let duration = now
193        .duration_since(std::time::UNIX_EPOCH)
194        .unwrap_or_default();
195    let secs = duration.as_secs();
196    let days = secs / 86400;
197    // Howard Hinnant civil calendar algorithm (public domain).
198    let z = days as i64 + 719468;
199    let era = if z >= 0 { z } else { z - 146096 } / 146097;
200    let doe = (z - era * 146097) as u64;
201    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
202    let y = (yoe as i64) + era * 400;
203    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
204    let mp = (5 * doy + 2) / 153;
205    let d = doy - (153 * mp + 2) / 5 + 1;
206    let m = if mp < 10 { mp + 3 } else { mp - 9 };
207    let y = if m <= 2 { y + 1 } else { y };
208    format!("{y:04}-{m:02}-{d:02}")
209}