Skip to main content

yapi_core/
docs.rs

1//! The documentation the model reads about yapi: the yapi book and pi's
2//! README, docs and examples, as each release's `yapi-docs.tar.gz` packs
3//! them. install.sh unpacks the release's copy into `<agent dir>/docs`, and
4//! yapi downloads it on its first run when installed another way.
5
6use std::path::Path;
7
8use aws_lc_rs::digest::{SHA256, digest};
9
10/// Where yapi's releases are published.
11const RELEASES_URL: &str = concat!(env!("CARGO_PKG_REPOSITORY"), "/releases");
12/// The docs archive in each release.
13const ARCHIVE: &str = "yapi-docs.tar.gz";
14/// The file in the docs naming the yapi version they document.
15const VERSION_FILE: &str = ".version";
16
17/// The published yapi book.
18const BOOK_URL: &str = "https://nikolish.in/yapi/";
19/// pi's docs and examples at the release yapi follows.
20const PI_URL: &str = "https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent";
21
22/// The releases page yapi downloads its docs from and checks for a newer
23/// version, without a trailing slash: `YAPI_RELEASES_URL`, which install.sh
24/// reads too, else GitHub's.
25pub fn releases_url() -> String {
26    std::env::var("YAPI_RELEASES_URL")
27        .ok()
28        .map(|url| url.trim_end_matches('/').to_owned())
29        .filter(|url| !url.is_empty())
30        .unwrap_or_else(|| RELEASES_URL.to_owned())
31}
32
33/// Where the model reads the docs: the local copy in the agent directory
34/// when it documents this version, else the published pages.
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct Locations {
37    /// yapi's main page, in place of pi's `getReadmePath`.
38    pub main: String,
39    /// pi's docs, pi's `getDocsPath`.
40    pub pi_docs: String,
41    /// pi's examples, pi's `getExamplesPath`.
42    pub pi_examples: String,
43}
44
45impl Locations {
46    /// The local docs in `agent_dir` when they are this version's, else the
47    /// published ones, as after an upgrade until the new docs are downloaded.
48    pub fn find(agent_dir: &Path) -> Locations {
49        let dir = crate::config::docs_dir(agent_dir);
50        if current(&dir) {
51            Locations {
52                main: dir.join("index.md").display().to_string(),
53                pi_docs: dir.join("pi").join("docs").display().to_string(),
54                pi_examples: dir.join("pi").join("examples").display().to_string(),
55            }
56        } else {
57            Locations {
58                main: BOOK_URL.to_owned(),
59                pi_docs: format!("{PI_URL}/docs"),
60                pi_examples: format!("{PI_URL}/examples"),
61            }
62        }
63    }
64}
65
66/// Downloads this version's docs from the release in `releases` into
67/// `<agent_dir>/docs`, unless the docs there are this version's. The archive
68/// must match its published SHA-256. It is unpacked beside the old copy,
69/// which it then replaces.
70pub async fn download(releases: &str, agent_dir: &Path) -> Result<(), String> {
71    let version = env!("CARGO_PKG_VERSION");
72    let dir = crate::config::docs_dir(agent_dir);
73    if current(&dir) {
74        return Ok(());
75    }
76    let url = format!("{releases}/download/v{version}/{ARCHIVE}");
77    let archive = crate::tools::external::fetch(&url).await?;
78    let sums = crate::tools::external::fetch(&format!("{url}.sha256")).await?;
79    let expected = String::from_utf8_lossy(&sums)
80        .split_whitespace()
81        .next()
82        .unwrap_or_default()
83        .to_lowercase();
84    if crate::time::hex(digest(&SHA256, &archive).as_ref()) != expected {
85        return Err(format!("{url} does not match its SHA-256 checksum"));
86    }
87    tokio::task::spawn_blocking(move || replace(&archive, &dir))
88        .await
89        .map_err(|err| err.to_string())?
90}
91
92/// Whether the docs in `dir` document this version of yapi.
93fn current(dir: &Path) -> bool {
94    std::fs::read_to_string(dir.join(VERSION_FILE))
95        .is_ok_and(|text| text.trim() == env!("CARGO_PKG_VERSION"))
96}
97
98/// Unpacks `archive` next to `dir`, then puts it in place of `dir`. Should
99/// the last step fail, the model reads the published docs.
100fn replace(archive: &[u8], dir: &Path) -> Result<(), String> {
101    let fresh = dir.with_file_name(format!(".docs.{}", std::process::id()));
102    let result = tar::Archive::new(flate2::read::GzDecoder::new(archive))
103        .unpack(&fresh)
104        .map_err(|err| format!("could not unpack {ARCHIVE}: {err}"))
105        .and_then(|()| {
106            let _ = std::fs::remove_dir_all(dir);
107            std::fs::rename(&fresh, dir).map_err(|err| err.to_string())
108        });
109    let _ = std::fs::remove_dir_all(&fresh);
110    result
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn local_else_published_docs() {
119        let agent = std::env::temp_dir().join(format!("yapi-docs-find-{}", std::process::id()));
120        let _ = std::fs::remove_dir_all(&agent);
121        let published = Locations::find(&agent);
122        assert_eq!(published.main, "https://nikolish.in/yapi/");
123        assert_eq!(
124            published.pi_docs,
125            "https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs"
126        );
127        assert_eq!(
128            published.pi_examples,
129            "https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/examples"
130        );
131        std::fs::create_dir_all(agent.join("docs")).unwrap();
132        std::fs::write(agent.join("docs").join(VERSION_FILE), "0.0.1\n").unwrap();
133        assert_eq!(
134            Locations::find(&agent),
135            published,
136            "an older version's docs"
137        );
138        let version = format!("{}\n", env!("CARGO_PKG_VERSION"));
139        std::fs::write(agent.join("docs").join(VERSION_FILE), version).unwrap();
140        let local = Locations::find(&agent);
141        let docs = agent.join("docs");
142        assert_eq!(local.main, docs.join("index.md").display().to_string());
143        assert_eq!(
144            local.pi_docs,
145            docs.join("pi").join("docs").display().to_string()
146        );
147        assert_eq!(
148            local.pi_examples,
149            docs.join("pi").join("examples").display().to_string()
150        );
151        std::fs::remove_dir_all(&agent).unwrap();
152    }
153}