Skip to main content

kcode_rust_libs/
repository.rs

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