use std::path::{Path, PathBuf};
use std::process::Stdio;
use standard_plugin::sources::GUIDES;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Guide {
pub name: String,
pub title: String,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Origin {
Project { version: String, docs: PathBuf },
Builtin,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Guides {
pub origin: Origin,
pub guides: Vec<Guide>,
}
impl Guides {
#[must_use]
pub fn find(&self, name: &str) -> Option<&Guide> {
let name = name.strip_suffix(".md").unwrap_or(name);
self.guides
.iter()
.find(|guide| guide.name.eq_ignore_ascii_case(name))
}
#[must_use]
pub fn listing(&self) -> String {
let mut out = match &self.origin {
Origin::Project { version, docs } => format!(
"Guides of standard-plugin-sdk {version}, as this project's Cargo.lock resolves it ({}):\n",
docs.display()
),
Origin::Builtin => format!(
"Guides of standard-plugin-sdk {}, built into this CLI (no plugin project here):\n",
env!("CARGO_PKG_VERSION")
),
};
let width = self
.guides
.iter()
.map(|guide| guide.name.len())
.max()
.unwrap_or(0);
for guide in &self.guides {
out.push_str(&format!(" {:width$} {}\n", guide.name, guide.title));
}
out.push_str("Print one with `standard-plugin guide <name>`; start with agent-guide.\n");
out
}
}
#[must_use]
pub fn guides(dir: &Path) -> Guides {
let locked = locked_sdk(dir)
.and_then(|(version, docs)| read_docs(&docs).map(|guides| (version, docs, guides)));
match locked {
Some((version, docs, guides)) => Guides {
origin: Origin::Project { version, docs },
guides,
},
None => Guides {
origin: Origin::Builtin,
guides: GUIDES
.iter()
.map(|guide| Guide {
name: guide.name.into(),
title: guide.title().into(),
text: guide.text.into(),
})
.collect(),
},
}
}
fn locked_sdk(dir: &Path) -> Option<(String, PathBuf)> {
let manifest = [dir.to_path_buf()]
.into_iter()
.chain(
crate::project::plugins(dir)
.unwrap_or_default()
.into_iter()
.map(|plugin| plugin.dir),
)
.map(|dir| dir.join("Cargo.toml"))
.find(|path| path.is_file())?;
let output = crate::build::cargo()
.args(["metadata", "--format-version", "1", "--manifest-path"])
.arg(&manifest)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let sdk = metadata["packages"]
.as_array()?
.iter()
.find(|package| package["name"] == "standard-plugin-sdk")?;
let docs = Path::new(sdk["manifest_path"].as_str()?)
.parent()?
.join("docs");
Some((sdk["version"].as_str()?.to_string(), docs))
}
fn read_docs(docs: &Path) -> Option<Vec<Guide>> {
let mut guides: Vec<Guide> = std::fs::read_dir(docs)
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == "md"))
.filter_map(|path| {
let name = path.file_stem()?.to_str()?.to_string();
let text = std::fs::read_to_string(&path).ok()?;
let title = text
.lines()
.next()
.and_then(|line| line.strip_prefix("# "))
.unwrap_or(&name)
.to_string();
Some(Guide { name, title, text })
})
.collect();
let rank = |name: &str| {
GUIDES
.iter()
.position(|guide| guide.name == name)
.unwrap_or(GUIDES.len())
};
guides.sort_by(|a, b| {
rank(&a.name)
.cmp(&rank(&b.name))
.then_with(|| a.name.cmp(&b.name))
});
(!guides.is_empty()).then_some(guides)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outside_a_plugin_project_the_guides_are_the_built_in_ones() {
let temp = tempfile::tempdir().unwrap();
let found = guides(temp.path());
assert_eq!(found.origin, Origin::Builtin);
assert_eq!(found.guides.len(), GUIDES.len());
assert!(found.find("Agent-Guide.md").is_some());
assert!(found.find("no-such-guide").is_none());
}
}