Skip to main content

ljos_cli/
runner_env.rs

1//! Env files a shell runner sources.
2//!
3//! `onboard` writes the file and records those bytes in
4//! `.<filename>.ljos-wrote`. The first run whose shipped env bytes differ
5//! rewrites a file that is still those bytes, keeps lines the user added,
6//! and leaves a file it did not write alone. `ljos doctor` warns when a
7//! file it wrote is stale, and `ljos doctor --fix` rewrites it.
8
9use std::path::{Path, PathBuf};
10
11use sha2::{Digest, Sha256};
12
13const STALE_MARK: &str = " is stale; ljos doctor --fix";
14const EDITED_MARK: &str = " was edited by hand; ljos left it";
15const FOREIGN_MARK: &str = " was not written by ljos; ljos left it";
16
17fn step(detail: String, ok: bool) -> crate::Step {
18    crate::Step {
19        what: "env".into(),
20        detail,
21        ok,
22    }
23}
24
25fn mark_path(path: &Path) -> PathBuf {
26    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("env");
27    path.with_file_name(format!(".{name}.ljos-wrote"))
28}
29
30fn read_mark(path: &Path) -> Option<String> {
31    std::fs::read_to_string(mark_path(path))
32        .ok()
33        .filter(|text| !text.is_empty())
34}
35
36fn ensure_parent(path: &Path) -> std::io::Result<()> {
37    path.parent().map_or(Ok(()), std::fs::create_dir_all)
38}
39
40enum Kind {
41    Missing,
42    Current,
43    Stale(Vec<String>),
44    Hand,
45    Foreign,
46}
47
48/// Lines of `file` past the lines of `body`, in order. `None` when a
49/// line `body` wrote was changed or removed.
50fn added(body: &str, file: &str) -> Option<Vec<String>> {
51    let want: Vec<&str> = body.lines().collect();
52    let mut i = 0;
53    let mut extra = Vec::new();
54    for line in file.lines() {
55        if i < want.len() && line == want[i] {
56            i += 1;
57        } else if !line.trim().is_empty() {
58            extra.push(line.to_string());
59        }
60    }
61    (i == want.len()).then_some(extra)
62}
63
64fn looks_like_ours(text: &str) -> bool {
65    text.starts_with("# Source this in the shell that runs the agent's commands.\n")
66        && text.contains("LJOS_SEAT")
67        && (text.contains("_ljos_guard") || text.contains("ljos/guard-bin"))
68}
69
70/// A line an older env carried. Dropped only when the file has no record,
71/// so a shell block this ljos no longer writes is not appended.
72fn generated(line: &str) -> bool {
73    let t = line.trim();
74    t.is_empty()
75        || t.contains("LJOS_")
76        || t.contains("_ljos")
77        || t.starts_with("export PATH=")
78        || t.starts_with("unset ")
79        || t.starts_with("if ")
80        || t.starts_with("elif ")
81        || t.starts_with("case ")
82        || t.starts_with(";;")
83        || t.starts_with("*)")
84        || matches!(t, "fi" | "then" | "do" | "done" | "esac" | "else")
85}
86
87fn classify(path: &Path, template: &str) -> Kind {
88    let text = match std::fs::read_to_string(path) {
89        Ok(text) => text,
90        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Kind::Missing,
91        Err(_) => return Kind::Foreign,
92    };
93    if text == template {
94        return Kind::Current;
95    }
96    if let Some(body) = read_mark(path) {
97        return match added(&body, &text) {
98            Some(_) if body == template => Kind::Current,
99            Some(extra) => Kind::Stale(extra),
100            None => Kind::Hand,
101        };
102    }
103    if !looks_like_ours(&text) {
104        return Kind::Foreign;
105    }
106    let have: Vec<&str> = template.lines().collect();
107    Kind::Stale(
108        text.lines()
109            .filter(|line| !have.contains(line) && !generated(line))
110            .map(str::to_string)
111            .collect(),
112    )
113}
114
115fn compose(template: &str, kept: &[String]) -> String {
116    let mut out = template.to_string();
117    if !out.ends_with('\n') {
118        out.push('\n');
119    }
120    let have: Vec<&str> = template.lines().collect();
121    for line in kept {
122        if !have.contains(&line.as_str()) {
123            out.push_str(line);
124            out.push('\n');
125        }
126    }
127    out
128}
129
130fn export_name(line: &str) -> Option<&str> {
131    let name = line
132        .trim()
133        .strip_prefix("export ")?
134        .split(['=', ' ', '"', '\''])
135        .next()?
136        .trim();
137    (!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'))
138        .then_some(name)
139}
140
141/// `; added NAME; kept NAME`. Names the template already has are neither.
142fn change_suffix(before: &str, after: &str, template: &str) -> String {
143    let old: Vec<&str> = before.lines().filter_map(export_name).collect();
144    let new: Vec<&str> = after.lines().filter_map(export_name).collect();
145    let own: Vec<&str> = template.lines().filter_map(export_name).collect();
146    let added: Vec<&str> = new.iter().copied().filter(|n| !old.contains(n)).collect();
147    let kept: Vec<&str> = new
148        .iter()
149        .copied()
150        .filter(|n| old.contains(n) && !own.contains(n))
151        .collect();
152    let mut bits = Vec::new();
153    if !added.is_empty() {
154        bits.push(format!("added {}", added.join(", ")));
155    }
156    if !kept.is_empty() {
157        bits.push(format!("kept {}", kept.join(", ")));
158    }
159    if bits.is_empty() {
160        String::new()
161    } else {
162        format!("; {}", bits.join("; "))
163    }
164}
165
166fn write_new(path: &Path, text: &str, template: &str) -> std::io::Result<()> {
167    ensure_parent(path)?;
168    std::fs::write(path, text)?;
169    std::fs::write(mark_path(path), template)
170}
171
172/// Write the env file, or leave a hand edit and a file ljos did not write.
173/// A dry run writes nothing.
174pub(crate) fn install(path: &Path, name: &str, dry: bool) -> crate::Step {
175    let template = crate::seat_env_text(name);
176    let source = format!("source {}", path.display());
177    let verb = if dry { "would leave" } else { "left" };
178    match classify(path, &template) {
179        Kind::Current => {
180            if !dry && read_mark(path).as_deref() != Some(template.as_str()) {
181                if let Err(err) =
182                    ensure_parent(path).and_then(|()| std::fs::write(mark_path(path), &template))
183                {
184                    return step(format!("{}: {err}", path.display()), false);
185                }
186            }
187            step(format!("{} is current; {source}", path.display()), true)
188        }
189        Kind::Hand => step(
190            format!("{verb} {}; it was edited by hand", path.display()),
191            true,
192        ),
193        Kind::Foreign => step(
194            format!("{verb} {}; ljos did not write it", path.display()),
195            true,
196        ),
197        Kind::Missing => {
198            if dry {
199                return step(format!("would write {}; {source}", path.display()), true);
200            }
201            match write_new(path, &template, &template) {
202                Ok(()) => step(format!("wrote {}; {source}", path.display()), true),
203                Err(err) => step(format!("{}: {err}", path.display()), false),
204            }
205        }
206        Kind::Stale(kept) => {
207            let before = std::fs::read_to_string(path).unwrap_or_default();
208            let after = compose(&template, &kept);
209            let suffix = change_suffix(&before, &after, &template);
210            if dry {
211                return step(
212                    format!("would write {}; {source}{suffix}", path.display()),
213                    true,
214                );
215            }
216            match write_new(path, &after, &template) {
217                Ok(()) => step(format!("wrote {}; {source}{suffix}", path.display()), true),
218                Err(err) => step(format!("{}: {err}", path.display()), false),
219            }
220        }
221    }
222}
223
224/// Doctor's `runner env` row. A stale file ljos wrote, and a hand edit,
225/// are warnings ([`is_env_warning`]). A missing file, and a file ljos
226/// did not write, are not.
227pub(crate) fn doctor_env(name: &str, path: &Path) -> (bool, String) {
228    let template = crate::seat_env_text(name);
229    match classify(path, &template) {
230        Kind::Missing => (
231            false,
232            format!("{name}: no ljos env file; ljos onboard --harness {name}"),
233        ),
234        Kind::Current => (true, format!("{name}: source {}", path.display())),
235        Kind::Stale(_) => (false, format!("{name}: {}{STALE_MARK}", path.display())),
236        Kind::Hand => (false, format!("{name}: {}{EDITED_MARK}", path.display())),
237        Kind::Foreign => (false, format!("{name}: {}{FOREIGN_MARK}", path.display())),
238    }
239}
240
241pub(crate) fn is_env_warning(state: &str) -> bool {
242    state.contains(STALE_MARK) || state.contains(EDITED_MARK)
243}
244
245fn refresh_one(name: &str, path: &Path) -> bool {
246    let template = crate::seat_env_text(name);
247    if matches!(classify(path, &template), Kind::Missing) {
248        return true;
249    }
250    let before = std::fs::read_to_string(path).unwrap_or_default();
251    let done = install(path, name, false);
252    if !done.ok {
253        eprintln!("ljos: {}", done.detail);
254        return false;
255    }
256    let after = std::fs::read_to_string(path).unwrap_or_default();
257    if after != before {
258        eprintln!(
259            "ljos: refreshed {name}: {}{}",
260            path.display(),
261            change_suffix(&before, &after, &template)
262        );
263    } else if done.detail.contains("edited by hand") {
264        eprintln!("ljos: {name}: {}{EDITED_MARK}", path.display());
265    } else if done.detail.contains("did not write") {
266        eprintln!("ljos: {name}: {}{FOREIGN_MARK}", path.display());
267    }
268    true
269}
270
271/// Rewrite env files whose bytes are still what ljos wrote.
272///
273/// False when a rewrite could not be recorded, so the next run tries
274/// again. A hand edit, and a file ljos did not write, are not failures.
275pub fn refresh_installed() -> bool {
276    let all = match crate::harnesses_with_shipped(&crate::harnesses_path()) {
277        Ok(all) => all,
278        Err(err) => {
279            eprintln!("ljos: runner env files were not refreshed: {err:#}");
280            return true;
281        }
282    };
283    let mut held = true;
284    let mut seen = std::collections::BTreeSet::new();
285    for harness in &all.harness {
286        if !harness.shell {
287            continue;
288        }
289        let path = crate::env_path_for(&crate::harnesses_path(), harness);
290        if seen.insert(path.clone()) && !refresh_one(&harness.name, &path) {
291            held = false;
292        }
293    }
294    held
295}
296
297fn stamp_path() -> Option<PathBuf> {
298    let path = crate::data_base()?.join("ljos").join("env-refresh-version");
299    path.is_absolute().then_some(path)
300}
301
302fn refresh_identity() -> String {
303    let text = crate::seat_env_text("example_host");
304    hex::encode(Sha256::digest(text.as_bytes()))
305}
306
307fn write_stamp() {
308    let Some(path) = stamp_path() else {
309        return;
310    };
311    let stamp = refresh_identity();
312    if let Some(parent) = path.parent() {
313        let _ = std::fs::create_dir_all(parent);
314    }
315    let _ = std::fs::write(path, format!("{stamp}\n"));
316}
317
318/// The first run whose shipped env bytes differ from what this ljos
319/// already stamped. `ljos doctor` does not call this.
320pub fn refresh_on_new_version() {
321    let want = refresh_identity();
322    if stamp_path()
323        .is_some_and(|path| std::fs::read_to_string(path).is_ok_and(|text| text.trim() == want))
324    {
325        return;
326    }
327    if refresh_installed() {
328        write_stamp();
329    }
330}
331
332/// `ljos doctor --fix`: refresh even when this version already ran.
333pub fn refresh_for_fix() {
334    if refresh_installed() {
335        write_stamp();
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn a_missing_env_is_not_called_stale() {
345        let dir = tempfile::tempdir().unwrap();
346        let path = dir.path().join("example_host.env");
347        let (ok, state) = doctor_env("example_host", &path);
348        assert!(!ok, "{state}");
349        assert!(state.contains("no ljos env file"), "{state}");
350        assert!(!state.contains("is stale"), "{state}");
351    }
352
353    #[test]
354    fn install_records_the_bytes_and_a_dry_run_writes_nothing() {
355        let dir = tempfile::tempdir().unwrap();
356        let path = dir.path().join("example_host.env");
357        assert!(install(&path, "example_host", true)
358            .detail
359            .contains("would write"));
360        assert!(!path.exists() && !mark_path(&path).exists());
361        assert!(install(&path, "example_host", false)
362            .detail
363            .contains("wrote"));
364        let text = std::fs::read_to_string(&path).unwrap();
365        assert_eq!(text, crate::seat_env_text("example_host"));
366        assert_eq!(std::fs::read_to_string(mark_path(&path)).unwrap(), text);
367        assert!(install(&path, "example_host", false)
368            .detail
369            .contains("is current"));
370    }
371
372    #[test]
373    fn a_current_file_with_no_record_is_marked_and_not_rewritten() {
374        let dir = tempfile::tempdir().unwrap();
375        let path = dir.path().join("example_host.env");
376        let text = crate::seat_env_text("example_host");
377        std::fs::write(&path, &text).unwrap();
378        assert!(install(&path, "example_host", false)
379            .detail
380            .contains("is current"));
381        assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
382        assert_eq!(read_mark(&path).as_deref(), Some(text.as_str()));
383    }
384}