kcode-k1-rust-worktree 0.1.0

Represent K1 Rust unpublished worktrees and source selectors
Documentation
use kcode_k1_rust_package::{LibraryId, SourceFile, SourcePackage};
use kcode_k1_transaction_id::TxId;
use semver::Version;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use toml_edit::{DocumentMut, value};

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct UnpublishedId(TxId);

impl UnpublishedId {
    pub const fn new(transaction: TxId) -> Self {
        Self(transaction)
    }

    pub const fn transaction(&self) -> TxId {
        self.0
    }
}

impl Display for UnpublishedId {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        Display::fmt(&self.0, formatter)
    }
}

impl FromStr for UnpublishedId {
    type Err = WorktreeError;

    fn from_str(source: &str) -> Result<Self, Self::Err> {
        let transaction = TxId::from_str(source)
            .map_err(|_| WorktreeError("invalid unpublished version".into()))?;
        if transaction.to_string() != source {
            return fail("invalid unpublished version");
        }
        Ok(Self(transaction))
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SourceSelector {
    Published(Version),
    Unpublished(UnpublishedId),
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Worktree {
    id: UnpublishedId,
    revision: TxId,
    source: SourcePackage,
}

impl Worktree {
    pub fn new(id: UnpublishedId, revision: TxId, source: SourcePackage) -> Self {
        Self {
            id,
            revision,
            source,
        }
    }

    pub const fn id(&self) -> UnpublishedId {
        self.id
    }

    pub const fn revision(&self) -> TxId {
        self.revision
    }

    pub fn source(&self) -> &SourcePackage {
        &self.source
    }

    pub fn replace(
        &mut self,
        expected_revision: TxId,
        revision: TxId,
        source: SourcePackage,
    ) -> Result<(), WorktreeError> {
        if self.revision != expected_revision {
            return fail("unpublished version changed");
        }
        if source.id() != self.source.id() {
            return fail("unpublished source identity changed");
        }
        self.revision = revision;
        self.source = source;
        Ok(())
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OpenedSource {
    selector: SourceSelector,
    source: SourcePackage,
    revision: Option<TxId>,
}

impl OpenedSource {
    pub fn published(version: Version, source: SourcePackage) -> Self {
        Self {
            selector: SourceSelector::Published(version),
            source,
            revision: None,
        }
    }

    pub fn unpublished(worktree: &Worktree) -> Self {
        Self {
            selector: SourceSelector::Unpublished(worktree.id()),
            source: worktree.source().clone(),
            revision: Some(worktree.revision()),
        }
    }

    pub fn selector(&self) -> &SourceSelector {
        &self.selector
    }

    pub fn source(&self) -> &SourcePackage {
        &self.source
    }

    pub const fn revision(&self) -> Option<TxId> {
        self.revision
    }

    pub fn into_source(self) -> SourcePackage {
        self.source
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorktreeError(String);

impl WorktreeError {
    pub fn message(&self) -> &str {
        &self.0
    }
}

impl Display for WorktreeError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for WorktreeError {}

pub fn publication_source(
    source: &SourcePackage,
    version: Version,
) -> Result<SourcePackage, WorktreeError> {
    let manifest = source
        .files()
        .iter()
        .find(|file| file.path() == "Cargo.toml")
        .ok_or_else(|| WorktreeError("missing Cargo.toml".into()))?;
    let manifest = std::str::from_utf8(manifest.bytes())
        .map_err(|_| WorktreeError("Cargo.toml must be UTF-8".into()))?;
    let mut document = manifest
        .parse::<DocumentMut>()
        .map_err(|_| WorktreeError("Cargo.toml must be valid TOML".into()))?;
    let package = document
        .get_mut("package")
        .and_then(|item| item.as_table_mut())
        .ok_or_else(|| WorktreeError("missing [package]".into()))?;
    if !package.contains_key("version") {
        return fail("missing package version");
    }
    package["version"] = value(version.to_string());

    let mut files = source.files().to_vec();
    let target = files
        .iter_mut()
        .find(|file| file.path() == "Cargo.toml")
        .expect("manifest was found");
    *target = SourceFile::new("Cargo.toml", document.to_string().into_bytes());
    let identity = LibraryId::new(source.id().family().clone(), version)
        .map_err(|error| WorktreeError(error.to_string()))?;
    SourcePackage::new(identity, files).map_err(|error| WorktreeError(error.to_string()))
}

fn fail<T>(message: impl Into<String>) -> Result<T, WorktreeError> {
    Err(WorktreeError(message.into()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use kcode_k1_rust_package::{AuthorityId, LibraryFamily};

    fn source() -> SourcePackage {
        let family =
            LibraryFamily::new(AuthorityId::new(TxId::from_bytes([1; 12])), "demo").unwrap();
        let identity = LibraryId::new(family, Version::new(0, 0, 0)).unwrap();
        let manifest = r#"[package]
name = "k1-010101010101010101010101-demo"
version = "0.0.0"
edition = "2024"
autobins = false
autoexamples = false
autotests = false
autobenches = false

[lib]
name = "demo"
path = "src/lib.rs"

[workspace]
resolver = "3"
"#;
        SourcePackage::new(
            identity,
            vec![
                SourceFile::new("Cargo.toml", manifest.as_bytes().to_vec()),
                SourceFile::new("Documentation.md", b"docs\n".to_vec()),
                SourceFile::new("src/lib.rs", b"pub fn run() {}\n".to_vec()),
            ],
        )
        .unwrap()
    }

    #[test]
    fn unpublished_ids_are_canonical_transaction_ids() {
        let id = UnpublishedId::new(TxId::from_bytes([7; 12]));
        assert_eq!(id.to_string().len(), 24);
        assert_eq!(id.to_string().parse::<UnpublishedId>().unwrap(), id);
        assert!("ABCDEFABCDEFABCDEFABCDEF".parse::<UnpublishedId>().is_err());
    }

    #[test]
    fn publication_rewrites_only_manifest_version_semantics() {
        let original = source();
        let published = publication_source(&original, Version::new(1, 2, 3)).unwrap();
        assert_eq!(published.id().version(), &Version::new(1, 2, 3));
        assert!(
            std::str::from_utf8(published.files()[0].bytes())
                .unwrap()
                .contains("version = \"1.2.3\"")
        );
        assert_eq!(original.id().version(), &Version::new(0, 0, 0));
        assert_eq!(published.files()[1..], original.files()[1..]);
    }
}