Skip to main content

standard_plugin_cli/
guide.rs

1//! `standard-plugin guide`: the SDK's guides. In a plugin project they come
2//! from the SDK its `Cargo.lock` resolves (that crate's `docs/` directory,
3//! found with `cargo metadata`), so they describe the SDK the plugin builds
4//! against. Anywhere else they are the guides of the SDK this CLI was
5//! released with.
6
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9
10use standard_plugin::sources::GUIDES;
11
12/// One guide: its name (the file stem in `docs/`), title and Markdown.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Guide {
15    pub name: String,
16    pub title: String,
17    pub text: String,
18}
19
20/// Where the guides came from.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum Origin {
23    /// The SDK the project resolves: its version and `docs/` directory.
24    Project { version: String, docs: PathBuf },
25    /// The guides built into this CLI.
26    Builtin,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct Guides {
31    pub origin: Origin,
32    pub guides: Vec<Guide>,
33}
34
35impl Guides {
36    /// The guide named `name`, ignoring case and a `.md` suffix.
37    #[must_use]
38    pub fn find(&self, name: &str) -> Option<&Guide> {
39        let name = name.strip_suffix(".md").unwrap_or(name);
40        self.guides
41            .iter()
42            .find(|guide| guide.name.eq_ignore_ascii_case(name))
43    }
44
45    /// The list `standard-plugin guide` prints without a name.
46    #[must_use]
47    pub fn listing(&self) -> String {
48        let mut out = match &self.origin {
49            Origin::Project { version, docs } => format!(
50                "Guides of standard-plugin-sdk {version}, as this project's Cargo.lock resolves it ({}):\n",
51                docs.display()
52            ),
53            Origin::Builtin => format!(
54                "Guides of standard-plugin-sdk {}, built into this CLI (no plugin project here):\n",
55                env!("CARGO_PKG_VERSION")
56            ),
57        };
58        let width = self
59            .guides
60            .iter()
61            .map(|guide| guide.name.len())
62            .max()
63            .unwrap_or(0);
64        for guide in &self.guides {
65            out.push_str(&format!("  {:width$}  {}\n", guide.name, guide.title));
66        }
67        out.push_str("Print one with `standard-plugin guide <name>`; start with agent-guide.\n");
68        out
69    }
70}
71
72/// The guides for the plugin project at `dir`.
73#[must_use]
74pub fn guides(dir: &Path) -> Guides {
75    let locked = locked_sdk(dir)
76        .and_then(|(version, docs)| read_docs(&docs).map(|guides| (version, docs, guides)));
77    match locked {
78        Some((version, docs, guides)) => Guides {
79            origin: Origin::Project { version, docs },
80            guides,
81        },
82        None => Guides {
83            origin: Origin::Builtin,
84            guides: GUIDES
85                .iter()
86                .map(|guide| Guide {
87                    name: guide.name.into(),
88                    title: guide.title().into(),
89                    text: guide.text.into(),
90                })
91                .collect(),
92        },
93    }
94}
95
96/// The version and `docs/` directory of the `standard-plugin-sdk` the
97/// project at `dir` resolves, or `None` outside a plugin project or when
98/// `cargo metadata` cannot say.
99fn locked_sdk(dir: &Path) -> Option<(String, PathBuf)> {
100    let manifest = [dir.to_path_buf()]
101        .into_iter()
102        .chain(
103            crate::project::plugins(dir)
104                .unwrap_or_default()
105                .into_iter()
106                .map(|plugin| plugin.dir),
107        )
108        .map(|dir| dir.join("Cargo.toml"))
109        .find(|path| path.is_file())?;
110    let output = crate::build::cargo()
111        .args(["metadata", "--format-version", "1", "--manifest-path"])
112        .arg(&manifest)
113        .stdin(Stdio::null())
114        .stderr(Stdio::null())
115        .output()
116        .ok()?;
117    if !output.status.success() {
118        return None;
119    }
120    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
121    let sdk = metadata["packages"]
122        .as_array()?
123        .iter()
124        .find(|package| package["name"] == "standard-plugin-sdk")?;
125    let docs = Path::new(sdk["manifest_path"].as_str()?)
126        .parent()?
127        .join("docs");
128    Some((sdk["version"].as_str()?.to_string(), docs))
129}
130
131/// The guides in a `docs/` directory: the ones this CLI knows in their
132/// reading order, then any others by name. `None` without any.
133fn read_docs(docs: &Path) -> Option<Vec<Guide>> {
134    let mut guides: Vec<Guide> = std::fs::read_dir(docs)
135        .ok()?
136        .filter_map(Result::ok)
137        .map(|entry| entry.path())
138        .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
139        .filter_map(|path| {
140            let name = path.file_stem()?.to_str()?.to_string();
141            let text = std::fs::read_to_string(&path).ok()?;
142            let title = text
143                .lines()
144                .next()
145                .and_then(|line| line.strip_prefix("# "))
146                .unwrap_or(&name)
147                .to_string();
148            Some(Guide { name, title, text })
149        })
150        .collect();
151    let rank = |name: &str| {
152        GUIDES
153            .iter()
154            .position(|guide| guide.name == name)
155            .unwrap_or(GUIDES.len())
156    };
157    guides.sort_by(|a, b| {
158        rank(&a.name)
159            .cmp(&rank(&b.name))
160            .then_with(|| a.name.cmp(&b.name))
161    });
162    (!guides.is_empty()).then_some(guides)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn outside_a_plugin_project_the_guides_are_the_built_in_ones() {
171        let temp = tempfile::tempdir().unwrap();
172        let found = guides(temp.path());
173        assert_eq!(found.origin, Origin::Builtin);
174        assert_eq!(found.guides.len(), GUIDES.len());
175        assert!(found.find("Agent-Guide.md").is_some());
176        assert!(found.find("no-such-guide").is_none());
177    }
178}