1use std::collections::HashSet;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use crate::files::{
9 absolute_lexical, load_files, paths_overlap, validate_rust_lib_name, validate_write_target,
10 write_file,
11};
12use crate::sandbox;
13use crate::{CheckResult, Error, Result, RustLibDocs, RustLibFile};
14
15const DOCUMENTATION_FILE: &str = "Documentation.md";
16const MANIFEST_FILE: &str = "Cargo.toml";
17
18#[derive(Clone)]
19struct RegistryToken(Arc<str>);
20
21impl RegistryToken {
22 fn new(value: impl Into<String>) -> Result<Self> {
23 let value = value.into();
24 let value = value.trim();
25 if value.is_empty() {
26 return Err(Error::InvalidRegistryToken);
27 }
28 Ok(Self(Arc::from(value)))
29 }
30
31 fn expose(&self) -> &str {
32 self.0.as_ref()
33 }
34}
35
36impl fmt::Debug for RegistryToken {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str("RegistryToken([REDACTED])")
39 }
40}
41
42#[derive(Clone, Debug)]
44pub struct KcodeRustLibs {
45 rust_libs_root: PathBuf,
46 work_root: PathBuf,
47 registry_token: RegistryToken,
48}
49
50impl KcodeRustLibs {
51 pub fn new(
54 rust_libs_root: impl Into<PathBuf>,
55 crates_io_registry_token: impl Into<String>,
56 ) -> Result<Self> {
57 let registry_token = RegistryToken::new(crates_io_registry_token)?;
58 let rust_libs_lexical = absolute_lexical(&rust_libs_root.into())?;
59 let work_lexical = absolute_lexical(
60 &std::env::temp_dir().join(format!("kcode-rust-libs-work-{}", std::process::id())),
61 )?;
62 if paths_overlap(&rust_libs_lexical, &work_lexical) {
63 return Err(Error::RootsOverlap {
64 rust_libs_root: rust_libs_lexical,
65 work_root: work_lexical,
66 });
67 }
68
69 create_root(&rust_libs_lexical, "create Rust libraries root")?;
70 create_root(&work_lexical, "create work root")?;
71
72 let rust_libs_root = fs::canonicalize(&rust_libs_lexical).map_err(|source| {
73 Error::io(
74 "canonicalize Rust libraries root",
75 &rust_libs_lexical,
76 source,
77 )
78 })?;
79 let work_root = fs::canonicalize(&work_lexical)
80 .map_err(|source| Error::io("canonicalize work root", &work_lexical, source))?;
81
82 if paths_overlap(&rust_libs_root, &work_root) {
83 return Err(Error::RootsOverlap {
84 rust_libs_root,
85 work_root,
86 });
87 }
88
89 Ok(Self {
90 rust_libs_root,
91 work_root,
92 registry_token,
93 })
94 }
95
96 pub fn create_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
98 validate_rust_lib_name(name)?;
99 let root = self.rust_libs_root.join(name);
100
101 match fs::create_dir(&root) {
102 Ok(()) => {}
103 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
104 return Err(Error::RustLibAlreadyExists(name.to_owned()));
105 }
106 Err(source) => return Err(Error::io("create Rust library", root, source)),
107 }
108
109 let created = (|| {
110 let source_directory = root.join("src");
111 fs::create_dir(&source_directory).map_err(|source| {
112 Error::io(
113 "create Rust library source directory",
114 &source_directory,
115 source,
116 )
117 })?;
118
119 let manifest = format!(
120 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n"
121 );
122 write_new_file(&root.join(MANIFEST_FILE), &manifest, "write Cargo manifest")?;
123 write_new_file(
124 &root.join(DOCUMENTATION_FILE),
125 "",
126 "write Rust library documentation",
127 )?;
128 write_new_file(&source_directory.join("lib.rs"), "", "write library source")?;
129 Ok(())
130 })();
131
132 if let Err(error) = created {
133 let _ = fs::remove_dir_all(&root);
134 return Err(error);
135 }
136
137 self.open_validated(name, root)
138 }
139
140 pub fn open_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
142 validate_rust_lib_name(name)?;
143 let root = self.rust_libs_root.join(name);
144 let metadata = match fs::symlink_metadata(&root) {
145 Ok(metadata) => metadata,
146 Err(source) if source.kind() == io::ErrorKind::NotFound => {
147 return Err(Error::RustLibNotFound(name.to_owned()));
148 }
149 Err(source) => return Err(Error::io("inspect Rust library", root, source)),
150 };
151
152 if metadata.file_type().is_symlink() {
153 return Err(Error::SymlinkNotAllowed(root));
154 }
155 if !metadata.is_dir() {
156 return Err(Error::RustLibIsNotDirectory(name.to_owned()));
157 }
158
159 self.open_validated(name, root)
160 }
161
162 fn open_validated(&self, name: &str, root: PathBuf) -> Result<OpenedRustLib> {
163 let files = load_files(&root)?;
164 let docs = validate_rust_lib_metadata(&files)?;
165 Ok(OpenedRustLib {
166 name: name.to_owned(),
167 root,
168 work_root: self.work_root.clone(),
169 registry_token: self.registry_token.clone(),
170 files,
171 docs,
172 })
173 }
174}
175
176fn create_root(path: &Path, action: &'static str) -> Result<()> {
177 fs::create_dir_all(path).map_err(|source| Error::io(action, path, source))?;
178 let metadata = fs::metadata(path).map_err(|source| Error::io("inspect root", path, source))?;
179 if !metadata.is_dir() {
180 return Err(Error::io(
181 "use root as a directory",
182 path,
183 io::Error::new(io::ErrorKind::NotADirectory, "path is not a directory"),
184 ));
185 }
186 Ok(())
187}
188
189fn write_new_file(path: &Path, contents: &str, action: &'static str) -> Result<()> {
190 fs::write(path, contents).map_err(|source| Error::io(action, path, source))
191}
192
193#[derive(Debug)]
195pub struct OpenedRustLib {
196 name: String,
197 root: PathBuf,
198 work_root: PathBuf,
199 registry_token: RegistryToken,
200 files: Vec<RustLibFile>,
201 docs: RustLibDocs,
202}
203
204impl OpenedRustLib {
205 pub fn name(&self) -> &str {
206 &self.name
207 }
208
209 pub fn files(&self) -> &[RustLibFile] {
210 &self.files
211 }
212
213 pub fn docs(&self) -> &RustLibDocs {
215 &self.docs
216 }
217
218 pub fn write(&mut self, files: &[RustLibFile]) -> Result<()> {
220 let mut unique_paths = HashSet::with_capacity(files.len());
221 for file in files {
222 if !unique_paths.insert(file.path.clone()) {
223 return Err(Error::DuplicateWritePath(file.path.to_string()));
224 }
225 validate_write_target(&self.root, &file.path)?;
226 }
227
228 let current_files = load_files(&self.root)?;
229 validate_projected_metadata(¤t_files, files)?;
230
231 for file in files {
232 write_file(&self.root, file)?;
233 }
234
235 let reloaded = load_files(&self.root)?;
236 let docs = validate_rust_lib_metadata(&reloaded)?;
237 self.files = reloaded;
238 self.docs = docs;
239 Ok(())
240 }
241
242 pub fn check(&self) -> Result<CheckResult> {
244 sandbox::check(&self.root, &self.work_root)
245 }
246
247 pub fn publish(&self) -> Result<()> {
250 let check = self.check()?;
251 if !check.passed() {
252 return Err(Error::CheckFailed(check));
253 }
254
255 sandbox::publish(&self.root, &self.work_root, self.registry_token.expose())
256 }
257}
258
259fn validate_projected_metadata(
260 current: &[RustLibFile],
261 writes: &[RustLibFile],
262) -> Result<RustLibDocs> {
263 let projected = |path: &str| {
264 writes
265 .iter()
266 .find(|file| file.path.as_str() == path)
267 .or_else(|| current.iter().find(|file| file.path.as_str() == path))
268 .map(|file| file.contents.as_str())
269 };
270 validate_metadata_text(projected(DOCUMENTATION_FILE), projected(MANIFEST_FILE))
271}
272
273fn validate_rust_lib_metadata(files: &[RustLibFile]) -> Result<RustLibDocs> {
274 let contents = |path: &str| {
275 files
276 .iter()
277 .find(|file| file.path.as_str() == path)
278 .map(|file| file.contents.as_str())
279 };
280 validate_metadata_text(contents(DOCUMENTATION_FILE), contents(MANIFEST_FILE))
281}
282
283fn validate_metadata_text(
284 documentation: Option<&str>,
285 manifest: Option<&str>,
286) -> Result<RustLibDocs> {
287 let documentation = documentation.ok_or(Error::MissingRequiredFile(DOCUMENTATION_FILE))?;
288 let manifest = manifest.ok_or(Error::MissingRequiredFile(MANIFEST_FILE))?;
289 let version = parse_version(&manifest_package_version(manifest)?)?;
290 Ok(RustLibDocs {
291 version,
292 documentation: documentation.to_owned(),
293 })
294}
295
296fn parse_version(value: &str) -> Result<String> {
297 let components = value.split('.').collect::<Vec<_>>();
298 let valid_component = |component: &str| {
299 !component.is_empty()
300 && component.bytes().all(|byte| byte.is_ascii_digit())
301 && (component == "0" || !component.starts_with('0'))
302 };
303
304 if components.len() != 3 || !components.into_iter().all(valid_component) {
305 return Err(Error::InvalidVersion(value.to_owned()));
306 }
307
308 Ok(value.to_owned())
309}
310
311fn manifest_package_version(manifest: &str) -> Result<String> {
312 let mut in_package = false;
313 let mut version = None;
314
315 for raw_line in manifest.lines() {
316 let line = strip_toml_comment(raw_line).trim();
317 if line.is_empty() {
318 continue;
319 }
320 if line.starts_with('[') {
321 in_package = line
322 .strip_prefix('[')
323 .and_then(|value| value.strip_suffix(']'))
324 .is_some_and(|value| value.trim() == "package");
325 continue;
326 }
327 if !in_package {
328 continue;
329 }
330
331 let Some((key, value)) = line.split_once('=') else {
332 continue;
333 };
334 if key.trim() != "version" {
335 continue;
336 }
337 if version.is_some() {
338 return Err(Error::InvalidCargoManifest(
339 "[package] declares version more than once".to_owned(),
340 ));
341 }
342
343 let value = value.trim();
344 let unquoted = value
345 .strip_prefix('"')
346 .and_then(|value| value.strip_suffix('"'))
347 .or_else(|| {
348 value
349 .strip_prefix('\'')
350 .and_then(|value| value.strip_suffix('\''))
351 })
352 .filter(|value| !value.contains(['"', '\'', '\\']))
353 .ok_or_else(|| {
354 Error::InvalidCargoManifest(
355 "[package].version must be a single-line literal string".to_owned(),
356 )
357 })?;
358 version = Some(unquoted.to_owned());
359 }
360
361 version.ok_or_else(|| {
362 Error::InvalidCargoManifest(
363 "the root manifest needs a literal version in [package]".to_owned(),
364 )
365 })
366}
367
368fn strip_toml_comment(line: &str) -> &str {
369 let mut quote = None;
370 let mut escaped = false;
371 for (index, character) in line.char_indices() {
372 match quote {
373 Some('"') if escaped => escaped = false,
374 Some('"') if character == '\\' => escaped = true,
375 Some(active) if character == active => quote = None,
376 Some(_) => {}
377 None if character == '"' || character == '\'' => quote = Some(character),
378 None if character == '#' => return &line[..index],
379 None => {}
380 }
381 }
382 line
383}
384
385#[cfg(test)]
386mod tests {
387 use super::{parse_version, validate_rust_lib_metadata};
388 use crate::{Error, RustLibFile, RustLibPath};
389
390 fn file(path: &str, contents: &str) -> RustLibFile {
391 RustLibFile::new(RustLibPath::new(path).unwrap(), contents)
392 }
393
394 #[test]
395 fn accepts_only_canonical_stable_versions() {
396 for value in ["0.1.0", "12.34.56"] {
397 assert!(parse_version(value).is_ok(), "{value:?}");
398 }
399 for value in [
400 "v0.1.0",
401 "01.2.3",
402 "0.1",
403 "0.1.0-beta.1",
404 "0.1.0+build",
405 " 0.1.0 ",
406 ] {
407 assert!(
408 matches!(parse_version(value), Err(Error::InvalidVersion(_))),
409 "{value:?}"
410 );
411 }
412 }
413
414 #[test]
415 fn reads_the_canonical_version_from_the_root_manifest() {
416 let files = vec![
417 file(
418 "Cargo.toml",
419 "[package]\nname = \"demo#one\"\nversion = '2.0.0' # current\n",
420 ),
421 file("Documentation.md", "docs\n"),
422 ];
423 let docs = validate_rust_lib_metadata(&files).unwrap();
424 assert_eq!(docs.version, "2.0.0");
425 assert_eq!(docs.documentation, "docs\n");
426
427 let inherited = vec![
428 file(
429 "Cargo.toml",
430 "[package]\nname = \"demo\"\nversion.workspace = true\n",
431 ),
432 file("Documentation.md", ""),
433 ];
434 assert!(matches!(
435 validate_rust_lib_metadata(&inherited),
436 Err(Error::InvalidCargoManifest(_))
437 ));
438 }
439}