//! Reading what a project says about itself. //! //! A project's `resources:` declaration points at values the project //! keeps for its own use -- its name, where its data and config live -- //! and this reads the few of them this server needs. The file belongs to //! the project: its own code is expected to read the same one, which is //! why unrecognised keys are ignored rather than refused. //! //! **Off the request path, like every other slow answer here.** The //! `Script` variant spawns a process, and `/manifest` is fetched on every //! open, resume and Refresh -- the one thing that path must not do. So //! this goes through [`crate::checks`] beside the git and service checks, //! answers arriving after the response that started them, and the phone //! waiting on all three through the same outstanding count. //! //! Not knowing is a first-class answer. A project that declares nothing, //! a file that will not parse, a script that fails: each leaves the //! Uninstall dialog saying it cannot tell where the data lives, rather //! than filling in a directory that looks plausible. The version of this //! that guessed -- first from the config key, then from the checkout's //! directory name -- was wrong in a way nothing could see, because a path //! that is not there reads as "this component keeps nothing here". use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::checks::{Checks, Report}; use crate::config::{ResourceFacts, Resources}; /// How long a resources script is given before it is treated as having /// failed. /// /// It prints three values; anything that takes longer than this is stuck /// rather than slow, and a check that never finishes holds the key /// claimed forever -- so the card would sit on "still finding out" with /// nothing ever arriving. const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); /// Reads a project's resources, or says why it could not. /// /// The error is what the card shows, so it names the file or the command /// rather than only the underlying complaint -- "no such file" without a /// path is not something a person can act on. pub fn read(project: &Path, declaration: &Resources) -> Result { match declaration { Resources::Inline(facts) => Ok(facts.clone()), Resources::Ron(path) => { // `~` expanded the same way it is for a path typed on a // phone; `join` on an absolute path yields that path, so a // resources file may live outside the checkout. let path = project.join( path.to_str() .map(crate::config::expand_tilde) .unwrap_or_else(|| path.clone()), ); let text = std::fs::read_to_string(&path) .map_err(|err| format!("reading {}: {err}", path.display()))?; parse(&text).map_err(|err| format!("in {}: {err}", path.display())) } Resources::Script(command) => { let text = run(project, command)?; parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line())) } } } /// The project's own house rules, which are this server's: the file is the /// *body* of the struct, and an optional value is written bare. /// /// Shared with `wg_app_link::format` rather than reimplemented, because /// the project's own code parses the same file and the two must agree /// about whether it has outer parentheses. fn parse(text: &str) -> Result { wg_app_link::format::parse(text).map_err(|err| err.to_string()) } fn run(project: &Path, command: &crate::config::Command) -> Result { use std::io::Read; let mut child = command .to_process(project, None, &[])? // Closed, because a script that asks a question would otherwise // wait for an answer nobody is there to give -- the same rule the // service scripts run under. .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|err| format!("starting {}: {err}", command.to_line()))?; let deadline = std::time::Instant::now() + SCRIPT_TIMEOUT; loop { match child.try_wait() { Err(err) => return Err(format!("waiting on {}: {err}", command.to_line())), Ok(Some(status)) => { let mut out = String::new(); if let Some(mut pipe) = child.stdout.take() { let _ = pipe.read_to_string(&mut out); } if status.success() { return Ok(out); } let mut err = String::new(); if let Some(mut pipe) = child.stderr.take() { let _ = pipe.read_to_string(&mut err); } return Err(format!( "{} failed ({status}): {}", command.to_line(), err.lines().next().unwrap_or("no output").trim() )); } Ok(None) if std::time::Instant::now() >= deadline => { let _ = child.kill(); return Err(format!( "{} did not answer within {}s", command.to_line(), SCRIPT_TIMEOUT.as_secs() )); } Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)), } } } /// One project to read, and where from. pub struct Target { pub key: String, pub project: PathBuf, pub declaration: Resources, } /// The resources this server has read, by project key. /// /// A facade over [`Checks`] in the same shape as `git::RemoteChecks` and /// `service::ServiceChecks`, so all three are started, counted and /// reported the same way. #[derive(Default, Clone)] pub struct ResourceChecks(Arc>); impl ResourceChecks { /// What this project last said about itself. `None` before anything /// has been read, which is not the same as a project that declares /// nothing -- the caller knows which by whether there is a /// declaration at all. pub fn facts(&self, key: &str) -> Option { self.0.answer(key) } pub fn is_checking(&self, key: &str) -> bool { self.0.is_checking(key) } pub fn error(&self, key: &str) -> Option { self.0.error(key) } /// Reads every target that isn't already being read. Returns at once. pub fn refresh(&self, targets: Vec) { for target in targets { let Target { key, project, declaration, } = target; self.0 .start(key, move |_| Report::from(read(&project, &declaration))); } } /// Forgets a project being removed, so its key cannot be answered /// after nothing points at it. pub fn forget(&self, key: &str) { self.0.retain(|held| held != key); } } #[cfg(test)] mod tests { use super::*; #[test] fn a_file_is_read_as_the_body_of_the_struct() { let dir = std::env::temp_dir().join(format!("resources-ron-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("resources.ron"), "// what this project calls itself\nname: \"ai-app\",\n", ) .unwrap(); let facts = read(&dir, &Resources::Ron(PathBuf::from("resources.ron"))).unwrap(); assert_eq!(facts.name.as_deref(), Some("ai-app")); assert_eq!(facts.data, None, "not said is not a value to invent"); let _ = std::fs::remove_dir_all(&dir); } /// The project's file, so what this server does not recognise is /// somebody else's business rather than an error. #[test] fn keys_this_server_does_not_know_are_ignored() { let dir = std::env::temp_dir().join(format!("resources-extra-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( dir.join("r.ron"), "name: \"ai-app\",\nmodelCache: \"~/models\",\nport: 8443,\n", ) .unwrap(); let facts = read(&dir, &Resources::Ron(PathBuf::from("r.ron"))).unwrap(); assert_eq!(facts.name.as_deref(), Some("ai-app")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_missing_file_says_which_file() { let err = read( Path::new("/nowhere-at-all"), &Resources::Ron(PathBuf::from("r.ron")), ) .unwrap_err(); assert!(err.contains("r.ron"), "{err}"); } #[test] fn a_script_is_read_from_its_output() { let dir = std::env::temp_dir().join(format!("resources-script-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let script = dir.join("say.sh"); std::fs::write(&script, "#!/bin/sh\necho 'name: \"computed\",'\n").unwrap(); { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); } let facts = read( &dir, &Resources::Script(crate::config::Command::from_line("./say.sh")), ) .unwrap(); assert_eq!(facts.name.as_deref(), Some("computed")); let _ = std::fs::remove_dir_all(&dir); } /// A failing script is a state the card has a word for, not something /// to fall back from. #[test] fn a_failing_script_reports_rather_than_answering() { let dir = std::env::temp_dir().join(format!("resources-fail-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let script = dir.join("no.sh"); std::fs::write(&script, "#!/bin/sh\necho 'nope' >&2\nexit 3\n").unwrap(); { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); } let err = read( &dir, &Resources::Script(crate::config::Command::from_line("./no.sh")), ) .unwrap_err(); assert!(err.contains("nope"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } }