Skip to main content

kcode_rust_libs/
repository.rs

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