1use 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
48fn 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
70fn 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
141fn 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
172pub(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
224pub(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
271pub 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
318pub 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
332pub 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}