standard_plugin_cli/
guide.rs1use std::path::{Path, PathBuf};
8use std::process::Stdio;
9
10use standard_plugin::sources::GUIDES;
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Guide {
15 pub name: String,
16 pub title: String,
17 pub text: String,
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum Origin {
23 Project { version: String, docs: PathBuf },
25 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 #[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 #[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#[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
96fn 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
131fn 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}