1use std::path::Path;
7
8use aws_lc_rs::digest::{SHA256, digest};
9
10const RELEASES_URL: &str = concat!(env!("CARGO_PKG_REPOSITORY"), "/releases");
12const ARCHIVE: &str = "yapi-docs.tar.gz";
14const VERSION_FILE: &str = ".version";
16
17const BOOK_URL: &str = "https://nikolish.in/yapi/";
19const PI_URL: &str = "https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent";
21
22pub 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct Locations {
37 pub main: String,
39 pub pi_docs: String,
41 pub pi_examples: String,
43}
44
45impl Locations {
46 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
66pub 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
92fn 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
98fn 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}