Skip to main content

majordomus_cli/command_graph/
load.rs

1//! Loading the graph, at two speeds.
2//!
3//! A completion runs on every TAB and must feel like nothing at all; `commands graph` runs
4//! when a person asks and may take a moment. The difference is not a different graph — it
5//! is which inputs are read:
6//!
7//! | | clap tree | shell tool registry | capability registry | workflows | cache |
8//! |---|---|---|---|---|---|
9//! | [`fast`] | walked | read | composed | from the cache, when there is one | read |
10//! | [`full`] | walked | read | composed | asked of the runner | written |
11//!
12//! Neither loads the repository index. The index takes seconds to build and answers a
13//! question the command graph does not ask: nothing about *what commands exist* depends on
14//! what is in `.ai/`. That is the whole reason the completion can be synchronous.
15//!
16//! The workflows are the one input that costs a subprocess, so they are cached: [`full`]
17//! writes the graph beside the bridge it materialises, and [`fast`] reads it back. A cache
18//! that is missing or from another version of the executable is not an error — the fast
19//! path answers from the two declarations it can read directly, which is every command of
20//! both programs and none of the repository's own workflows.
21
22use std::path::{Path, PathBuf};
23
24use crate::capability::builtin;
25use crate::capability::registry::CapabilityRegistry;
26use crate::cli::RepoArgs;
27use crate::environment::workflows;
28use crate::error::{Error, Result};
29use crate::repository::Repository;
30use crate::share::Share;
31
32use super::{bridge, build, CommandGraph, Inputs};
33
34/// The file the full load writes and the fast load reads.
35pub const CACHE: &str = "graph.json";
36
37/// A loaded graph and where it came from.
38pub struct Loaded {
39    /// The graph.
40    pub graph: CommandGraph,
41    /// The repository root it describes.
42    pub root: PathBuf,
43    /// Whether the workflows in it came from the cache rather than from the runner.
44    pub cached: bool,
45}
46
47/// The capability registry, composed from the modules and nothing else.
48///
49/// No index, so no declarative capabilities and no filesystem walk. Every command-line
50/// exposure is declared by a builtin module, so the join is complete without one. Public
51/// because the completion answers `ValueSource::Capability` from it: composing the modules
52/// is arithmetic over compiled-in descriptors, which is why it is affordable on the hot
53/// path when building the index is not.
54pub fn registry() -> Option<CapabilityRegistry> {
55    CapabilityRegistry::builder()
56        .with_modules(builtin::modules())
57        .build()
58        .ok()
59}
60
61/// The whole graph, with the workflows asked of the runner, and the cache refreshed.
62pub fn full(repo: &RepoArgs) -> Result<Loaded> {
63    let root = root_of(repo)?;
64    let share = Share::locate(repo.share.as_deref(), &root).ok();
65    let registry = registry();
66    let catalogue = workflows::resolve(&root);
67    let graph = build(&Inputs {
68        registry: registry.as_ref(),
69        share: share.as_ref().map(|s| s.dir()),
70        workflows: Some(&catalogue),
71        bridged: bridge::bridged(&root),
72    });
73    let _ = write_cache(&root, &graph);
74    Ok(Loaded {
75        graph,
76        root,
77        cached: false,
78    })
79}
80
81/// The graph as fast as it can be had: no subprocess, no index, no network.
82pub fn fast(repo: &RepoArgs) -> Result<Loaded> {
83    let root = root_of(repo)?;
84    if let Some(graph) = read_cache(&root) {
85        return Ok(Loaded {
86            graph,
87            root,
88            cached: true,
89        });
90    }
91    let share = Share::locate(repo.share.as_deref(), &root).ok();
92    let registry = registry();
93    let graph = build(&Inputs {
94        registry: registry.as_ref(),
95        share: share.as_ref().map(|s| s.dir()),
96        workflows: None,
97        bridged: Default::default(),
98    });
99    Ok(Loaded {
100        graph,
101        root,
102        cached: false,
103    })
104}
105
106/// The graph of a repository whose root is already known, without asking the runner.
107///
108/// What a server answers from: it holds an index and therefore a root, it must not spawn a
109/// subprocess per request, and the cache the last materialisation wrote is exactly the
110/// answer it wants.
111pub fn fast_at(root: &Path, share: Option<&Path>) -> CommandGraph {
112    fast_with(root, share, None)
113}
114
115/// The same, for a caller that already holds a registry.
116///
117/// A server does. Building a second one to answer one page costs what building the first
118/// one cost and describes exactly the same executable: `/cockpit/commands` did that on
119/// every request whose cache was cold, which the no-rebuild sweep now refuses. `None` is
120/// for a caller with no registry of its own, which then pays for one.
121///
122/// ```
123/// use majordomus_cli::command_graph::load;
124/// let root = std::env::temp_dir().join(format!("mj-fast-with-{}", std::process::id()));
125/// std::fs::create_dir_all(&root).unwrap();
126/// // a cold cache is built from the registry the caller holds
127/// let held = load::registry();
128/// let graph = load::fast_with(&root, None, held.as_ref());
129/// // a warm cache is the answer, whichever registry is or is not held
130/// load::write_cache(&root, &graph).unwrap();
131/// assert_eq!(load::fast_with(&root, None, None), graph);
132/// std::fs::remove_dir_all(&root).unwrap();
133/// ```
134pub fn fast_with(
135    root: &Path,
136    share: Option<&Path>,
137    held: Option<&CapabilityRegistry>,
138) -> CommandGraph {
139    if let Some(graph) = read_cache(root) {
140        return graph;
141    }
142    match held {
143        Some(registry) => build(&Inputs {
144            registry: Some(registry),
145            share,
146            workflows: None,
147            bridged: Default::default(),
148        }),
149        None => {
150            let registry = registry();
151            build(&Inputs {
152                registry: registry.as_ref(),
153                share,
154                workflows: None,
155                bridged: Default::default(),
156            })
157        }
158    }
159}
160
161/// The whole graph of a repository whose root is already known.
162pub fn full_at(root: &Path, share: Option<&Path>) -> CommandGraph {
163    let registry = registry();
164    let catalogue = workflows::resolve(root);
165    build(&Inputs {
166        registry: registry.as_ref(),
167        share,
168        workflows: Some(&catalogue),
169        bridged: bridge::bridged(root),
170    })
171}
172
173/// Where the repository is.
174pub fn root(repo: &RepoArgs) -> Result<PathBuf> {
175    root_of(repo)
176}
177
178/// Where the repository is.
179fn root_of(repo: &RepoArgs) -> Result<PathBuf> {
180    let start = repo
181        .repo
182        .clone()
183        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
184    Ok(Repository::discover(&start)?.root().to_path_buf())
185}
186
187/// The cached graph, when there is one this executable wrote.
188///
189/// A cache from another schema is ignored rather than migrated: it costs one subprocess to
190/// produce a correct one, and reading a document whose shape is not known is how a cache
191/// becomes a source of wrong answers.
192pub fn read_cache(root: &Path) -> Option<CommandGraph> {
193    let text = std::fs::read_to_string(root.join(bridge::DIRECTORY).join(CACHE)).ok()?;
194    let graph: CommandGraph = serde_json::from_str(&text).ok()?;
195    (graph.schema == super::SCHEMA).then_some(graph)
196}
197
198/// Write the cache, beside the bridge, atomically.
199pub fn write_cache(root: &Path, graph: &CommandGraph) -> std::io::Result<()> {
200    let dir = root.join(bridge::DIRECTORY);
201    std::fs::create_dir_all(&dir)?;
202    let path = dir.join(CACHE);
203    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
204    std::fs::write(
205        &tmp,
206        serde_json::to_vec(graph).map_err(std::io::Error::other)?,
207    )?;
208    std::fs::rename(&tmp, &path)
209}
210
211/// The repository root, for a caller that has already discovered it.
212pub fn cache_path(root: &Path) -> PathBuf {
213    root.join(bridge::DIRECTORY).join(CACHE)
214}
215
216/// Map a repository error into the crate's own.
217pub fn missing_repository(e: Error) -> Error {
218    e
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn a_cache_round_trips_and_a_foreign_one_is_ignored() {
227        let dir = tempfile::tempdir().expect("a temporary directory");
228        let graph = build(&Inputs::default());
229        write_cache(dir.path(), &graph).expect("the cache is written");
230        let read = read_cache(dir.path()).expect("the cache is read back");
231        assert_eq!(read.fingerprint, graph.fingerprint);
232        assert_eq!(read.commands.len(), graph.commands.len());
233
234        std::fs::write(cache_path(dir.path()), br#"{"schema":"other/v9"}"#).unwrap();
235        assert!(read_cache(dir.path()).is_none());
236    }
237
238    /// With no cache, a caller that holds no registry pays for one and gets the same graph a
239    /// caller holding one gets: the registry describes the one executable either way. With a
240    /// cache, the cache is the answer and nothing is built.
241    #[test]
242    fn a_cold_graph_is_the_same_whoever_holds_the_registry() {
243        let dir = tempfile::tempdir().expect("a temporary directory");
244        let held = registry().expect("this executable's registry builds");
245        let built = fast_at(dir.path(), None);
246        assert!(
247            !built.commands.is_empty(),
248            "a cold graph is built, not empty"
249        );
250        assert_eq!(built, fast_with(dir.path(), None, Some(&held)));
251        assert!(
252            read_cache(dir.path()).is_none(),
253            "a fast read never writes the cache"
254        );
255
256        let cached = build(&Inputs::default());
257        write_cache(dir.path(), &cached).expect("the cache is written");
258        assert_eq!(fast_at(dir.path(), None), cached);
259        assert_eq!(fast_with(dir.path(), None, Some(&held)), cached);
260    }
261}