standard-plugin-cli 0.1.1

standard-plugin: scaffold, build, check and pack Standard Code plugins
Documentation
//! `standard-plugin guide`: the SDK's guides. In a plugin project they come
//! from the SDK its `Cargo.lock` resolves (that crate's `docs/` directory,
//! found with `cargo metadata`), so they describe the SDK the plugin builds
//! against. Anywhere else they are the guides of the SDK this CLI was
//! released with.

use std::path::{Path, PathBuf};
use std::process::Stdio;

use standard_plugin::sources::GUIDES;

/// One guide: its name (the file stem in `docs/`), title and Markdown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Guide {
    pub name: String,
    pub title: String,
    pub text: String,
}

/// Where the guides came from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Origin {
    /// The SDK the project resolves: its version and `docs/` directory.
    Project { version: String, docs: PathBuf },
    /// The guides built into this CLI.
    Builtin,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Guides {
    pub origin: Origin,
    pub guides: Vec<Guide>,
}

impl Guides {
    /// The guide named `name`, ignoring case and a `.md` suffix.
    #[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))
    }

    /// The list `standard-plugin guide` prints without a 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
    }
}

/// The guides for the plugin project at `dir`.
#[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(),
        },
    }
}

/// The version and `docs/` directory of the `standard-plugin-sdk` the
/// project at `dir` resolves, or `None` outside a plugin project or when
/// `cargo metadata` cannot say.
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))
}

/// The guides in a `docs/` directory: the ones this CLI knows in their
/// reading order, then any others by name. `None` without any.
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());
    }
}