diff --git a/Cargo.lock b/Cargo.lock index b8125e7..de4c506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,9 @@ version = "0.0.1-alpha.2" dependencies = [ "proc-macro2", "quote", + "semver", "syn", + "version-ranges", ] [[package]] @@ -29,6 +31,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + [[package]] name = "syn" version = "3.0.4" @@ -44,3 +58,13 @@ name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-ranges" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e9bd4e9c9ff6a2a9b5969462ba26216af3e010df0377dad8320ab515262ef8" +dependencies = [ + "semver", + "smallvec", +] diff --git a/Cargo.toml b/Cargo.toml index 4c6d3fa..b446f78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,6 @@ exclude = [".github", "rust-toolchain.toml"] [dependencies] proc-macro2 = "1.0.104" quote = "1.0.42" +semver = "1.0.27" +version-ranges = { version = "0.1.3", features = ["semver"] } syn = { version = "3.0.3", default-features = false, features = ["parsing"] } diff --git a/README.md b/README.md index 00d6ea2..f62dd37 100644 --- a/README.md +++ b/README.md @@ -35,22 +35,34 @@ Adding an item under an existing key appends those tokens, so a type and its `impl` blocks can accumulate separately and still render together. Each `Mod` also carries optional metadata: visibility, doc paragraphs, attributes. +`codespace` never parses, validates, or understands the fragments it holds, and +makes no naming decisions. Module names are the exception--invalid names +(including Rust keywords) are caller bugs and panic. See the crate docs for +details. + ## Output Generators can output tokens into a single `TokenStream` with `Codespace::into_stream` or into multiple files with `Codespace::into_files`, where each `TokenStream` is intended for a particular file path. The former is -good for proc macro implementation or calls from a `build.rs` file; the latter can -be well-suited for a stand-alone crate generator. +good for proc macro implementation or calls from a `build.rs` file; the latter +can be well-suited for a stand-alone crate generator. Both forms are deterministic and unformatted. Callers process the emitted `TokenStream`s and apply formatting as needed with [rustfmt](https://github.com/rust-lang/rustfmt) or [prettyplease](https://docs.rs/prettyplease). -`codespace` never parses, validates, or understands the fragments it holds, and -makes no naming decisions. Module names are the exception--invalid names -(including Rust keywords) are caller bugs and panic. See the crate docs for -details. +## Dependencies + +A `Codespace` may also include the external dependencies that its code +requires. Generators register what they emit with `Codespace::add_dependency`; +entries are kept by the identifier the code uses, and registrations under one +identifier merge. Nesting one codespace in another with +`Codespace::add_mod_from_codespace` carries its list along, and +`Codespace::to_toml_dependencies` writes the `[dependencies]` section of a +`Cargo.toml` for everything registered. codespace never infers a dependency +from the code it holds. + ## Notes diff --git a/src/dependency.rs b/src/dependency.rs new file mode 100644 index 0000000..9684fb1 --- /dev/null +++ b/src/dependency.rs @@ -0,0 +1,487 @@ +// Copyright 2026 Oxide Computer Company + +//! The external dependencies that generated code requires. + +use std::ops::Bound; + +use semver::{Comparator, Version, VersionReq}; +use version_ranges::Ranges; + +/// One line of a `[dependencies]` table: a crate the generated code +/// refers to and how a consumer should declare it. +/// +/// A generator registers one through +/// [`Codespace::add_dependency`](crate::Codespace::add_dependency); +/// [`Codespace::dependencies`](crate::Codespace::dependencies) answers +/// them all. [`Dependency::new`] is the common case; the rest is set by +/// struct update: +/// +/// ```rust +/// use codespace::Dependency; +/// +/// let chrono = Dependency { +/// version: "0.4".parse().unwrap(), +/// features: vec!["serde".to_string()], +/// default_features: Some(false), +/// ..Dependency::new("chrono") +/// }; +/// assert_eq!( +/// chrono.to_toml_inline(), +/// r#"chrono = { version = "0.4", default-features = false, features = ["serde"] }"# +/// ); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dependency { + /// The crate's name as a registry knows it. + pub name: String, + /// The identifier generated code uses for the crate when it differs + /// from the crate's name. + pub rename: Option, + /// The version requirement, as Cargo reads it; [`VersionReq::STAR`] + /// when any version will do. + pub version: VersionReq, + /// Features the generated code needs enabled, in any order. + pub features: Vec, + /// Whether the crate's default features are needed; a consumer writes + /// `default-features = false` for `Some(false)`. + pub default_features: Option, +} + +impl Dependency { + /// A dependency on the crate `name` at any version, with no features. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + rename: None, + version: VersionReq::STAR, + features: Vec::new(), + default_features: None, + } + } + + /// The identifier generated code uses for this crate: the rename if there + /// is one, otherwise the name with `-` as `_`, which is how Cargo exposes + /// a crate to code. + pub fn ident(&self) -> String { + self.toml_key().replace('-', "_") + } + + /// Fold a second registration under the same identifier into this + /// one, as [`Codespace::add_dependency`](crate::Codespace::add_dependency) + /// does. + /// + /// The caller has matched the identifiers; the two must name the same + /// crate. Features are unioned, and so is the need for default features: + /// one registration needing them is enough, and one leaving it unsaid + /// yields. Versions intersect; see [`intersect`]. + pub(crate) fn merge(&mut self, other: Dependency) -> Result<(), DependencyConflict> { + debug_assert_eq!(self.ident(), other.ident()); + if self.name != other.name { + return Err(DependencyConflict { + ident: self.ident(), + reason: format!( + "the crate `{}` in one place and `{}` in another", + self.name, other.name + ), + }); + } + match intersect(&self.version, &other.version) { + Some(version) => self.version = version, + None => { + return Err(DependencyConflict { + ident: self.ident(), + reason: format!( + "version {} in one place and {} in another", + self.version, other.version + ), + }); + } + } + self.features.extend(other.features); + self.features.sort(); + self.features.dedup(); + self.default_features = match (self.default_features, other.default_features) { + (Some(a), Some(b)) => Some(a || b), + (a, b) => a.or(b), + }; + Ok(()) + } + + /// The crate as one line of a `[dependencies]` table. + /// + /// A bare requirement renders as `chrono = "0.4"`; anything more + /// renders as an inline table, such as + /// `schemars08 = { version = "0.8", package = "schemars", features = ["derive"] }`. + pub fn to_toml_inline(&self) -> String { + let fields = self.toml_fields(); + match fields.as_slice() { + [version] if version.starts_with("version = ") => { + format!("{} = {}", self.toml_key(), &version["version = ".len()..]) + } + fields => format!("{} = {{ {} }}", self.toml_key(), fields.join(", ")), + } + } + + /// The crate as its own `[dependencies.]` table, one field per + /// line, ending in a newline. + pub fn to_toml_table(&self) -> String { + let mut out = format!("[dependencies.{}]\n", self.toml_key()); + for field in self.toml_fields() { + out.push_str(&field); + out.push('\n'); + } + out + } + + /// The key of the crate's dependency table: the rename, unless it is + /// the identifier the name already answers, or the crate's name as + /// the registry spells it, dashes and all. + fn toml_key(&self) -> &str { + match &self.rename { + Some(rename) if *rename != self.name.replace('-', "_") => rename, + _ => &self.name, + } + } + + /// The `key = value` fields of the crate's dependency table, in the + /// order Cargo manifests conventionally list them. + fn toml_fields(&self) -> Vec { + let mut fields = vec![format!("version = \"{}\"", toml_version(&self.version))]; + if self.toml_key() != self.name { + fields.push(format!("package = \"{}\"", self.name)); + } + if self.default_features == Some(false) { + fields.push("default-features = false".to_string()); + } + if !self.features.is_empty() { + let mut features = self.features.clone(); + features.sort(); + features.dedup(); + let features = features + .iter() + .map(|feature| format!("\"{feature}\"")) + .collect::>() + .join(", "); + fields.push(format!("features = [{features}]")); + } + fields + } +} + +/// The requirement that allows exactly the versions both `a` and `b` +/// allow, or `None` when there is no intersection. +fn intersect(a: &VersionReq, b: &VersionReq) -> Option { + if a == b { + return Some(a.clone()); + } + + // '*' matches anything so the other one wins + if *b == VersionReq::STAR { + return Some(a.clone()); + } + if *a == VersionReq::STAR { + return Some(b.clone()); + } + + // Do the full comparison; note that pre-release versions don't survive + // this trip. + let intersection = Ranges::from_req(a.clone()).intersection(&Ranges::from_req(b.clone())); + let mut intervals = intersection.iter(); + + let (lower, upper) = intervals.next()?; + assert!( + intervals.next().is_none(), + "two requirements each allow one interval, so they share at most one" + ); + Some(requirement(lower, upper)) +} + +/// The requirement allowing exactly the versions between `lower` and `upper`, +/// in its conventional spelling. +fn requirement(lower: &Bound, upper: &Bound) -> VersionReq { + let text = match (lower, upper) { + (Bound::Unbounded, Bound::Unbounded) => unreachable!("handled explicitly"), + (Bound::Included(a), Bound::Included(b)) if a == b => format!("={a}"), + (Bound::Included(a), Bound::Excluded(b)) if *b == caret_ceiling(a) => format!("^{a}"), + (Bound::Included(a), Bound::Excluded(b)) if *b == tilde_ceiling(a) => format!("~{a}"), + (lower, upper) => { + let lower = match lower { + Bound::Included(a) => Some(format!(">={a}")), + Bound::Excluded(a) => Some(format!(">{a}")), + Bound::Unbounded => None, + }; + let upper = match upper { + Bound::Included(b) => Some(format!("<={b}")), + Bound::Excluded(b) => Some(format!("<{b}")), + Bound::Unbounded => None, + }; + lower + .into_iter() + .chain(upper) + .collect::>() + .join(", ") + } + }; + text.parse() + .expect("a requirement written from version bounds parses") +} + +/// The first version a caret requirement on `v` excludes: the next +/// major, or for `0.x` the next minor, or for `0.0.x` the next patch. +fn caret_ceiling(v: &Version) -> Version { + match (v.major, v.minor) { + (0, 0) => Version::new(0, 0, v.patch + 1), + (0, minor) => Version::new(0, minor + 1, 0), + (major, _) => Version::new(major + 1, 0, 0), + } +} + +/// The first version a tilde requirement on `v` excludes: the next minor. +fn tilde_ceiling(v: &Version) -> Version { + Version::new(v.major, v.minor + 1, 0) +} + +/// A version as usually written in Cargo.toml: a lone caret requirement as the +/// bare version (`0.4`, `1.2.3`), since the caret is Cargo's default, and +/// anything else as semver renders it. +fn toml_version(version: &VersionReq) -> String { + if let [Comparator { + op: semver::Op::Caret, + major, + minor, + patch, + pre, + }] = version.comparators.as_slice() + { + let mut out = major.to_string(); + if let Some(minor) = minor { + out.push_str(&format!(".{minor}")); + if let Some(patch) = patch { + out.push_str(&format!(".{patch}")); + if !pre.is_empty() { + out.push_str(&format!("-{pre}")); + } + } + } + out + } else { + version.to_string() + } +} + +/// Two registrations under one identifier that cannot both be honored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyConflict { + /// The identifier the code uses; see [`Dependency::ident`]. + pub ident: String, + /// What disagreed. + pub reason: String, +} + +impl std::fmt::Display for DependencyConflict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "conflicting dependency `{}`: {}", + self.ident, self.reason + ) + } +} + +impl std::error::Error for DependencyConflict {} + +#[cfg(test)] +mod tests { + use super::*; + + fn req(s: &str) -> VersionReq { + s.parse().unwrap() + } + + fn strings(items: [&str; N]) -> Vec { + items.iter().map(|item| item.to_string()).collect() + } + + #[test] + fn ident_follows_cargo_naming() { + assert_eq!(Dependency::new("json-serde").ident(), "json_serde"); + assert_eq!(Dependency::new("serde_json").ident(), "serde_json"); + let renamed = Dependency { + rename: Some("js".to_string()), + ..Dependency::new("json-serde") + }; + assert_eq!(renamed.ident(), "js"); + } + + #[test] + fn a_rename_to_the_default_identifier_is_no_rename() { + let dep = Dependency { + rename: Some("json_serde".to_string()), + ..Dependency::new("json-serde") + }; + assert_eq!(dep.ident(), "json_serde"); + assert_eq!(dep.to_toml_inline(), "json-serde = \"*\""); + } + + #[test] + fn merge_unions_features_and_lets_star_yield() { + let mut dep = Dependency { + features: strings(["serde"]), + ..Dependency::new("uuid") + }; + dep.merge(Dependency { + version: req("1.0"), + features: strings(["v4", "serde"]), + ..Dependency::new("uuid") + }) + .unwrap(); + assert_eq!(dep.version, req("1.0")); + assert_eq!(dep.features, strings(["serde", "v4"])); + + // The other direction yields the same way. + dep.merge(Dependency::new("uuid")).unwrap(); + assert_eq!(dep.version, req("1.0")); + } + + #[test] + fn merge_unions_the_need_for_default_features() { + let mut dep = Dependency::new("chrono"); + assert_eq!(dep.default_features, None); + let said = |default_features| Dependency { + default_features: Some(default_features), + ..Dependency::new("chrono") + }; + dep.merge(said(false)).unwrap(); + assert_eq!(dep.default_features, Some(false)); + dep.merge(said(true)).unwrap(); + assert_eq!(dep.default_features, Some(true)); + dep.merge(Dependency::new("chrono")).unwrap(); + assert_eq!(dep.default_features, Some(true)); + } + + #[test] + fn toml_inline_is_bare_for_a_version_alone() { + assert_eq!( + Dependency::new("serde_json").to_toml_inline(), + "serde_json = \"*\"" + ); + let at = |name: &str, version: &str| Dependency { + version: req(version), + ..Dependency::new(name) + }; + assert_eq!(at("chrono", "0.4").to_toml_inline(), "chrono = \"0.4\""); + assert_eq!( + at("futures-core", "0.3.1").to_toml_inline(), + "futures-core = \"0.3.1\"" + ); + assert_eq!( + at("typespace", "0.0.1-alpha.2").to_toml_inline(), + "typespace = \"0.0.1-alpha.2\"" + ); + assert_eq!( + at("serde", ">=1.0, <2").to_toml_inline(), + "serde = \">=1.0, <2\"" + ); + } + + #[test] + fn toml_inline_is_a_table_for_anything_more() { + let dep = Dependency { + rename: Some("schemars08".to_string()), + version: req("0.8"), + features: strings(["derive"]), + default_features: Some(false), + ..Dependency::new("schemars") + }; + assert_eq!( + dep.to_toml_inline(), + "schemars08 = { version = \"0.8\", package = \"schemars\", \ + default-features = false, features = [\"derive\"] }" + ); + } + + #[test] + fn toml_table_lists_one_field_per_line_with_features_sorted() { + let dep = Dependency { + version: req("1.0"), + features: strings(["v4", "serde"]), + ..Dependency::new("uuid") + }; + assert_eq!( + dep.to_toml_table(), + "[dependencies.uuid]\nversion = \"1.0\"\nfeatures = [\"serde\", \"v4\"]\n" + ); + } + + #[test] + fn merge_intersects_versions() { + let at = |version: &str| Dependency { + version: req(version), + ..Dependency::new("uuid") + }; + let merged = |a: &str, b: &str| { + let mut dep = at(a); + dep.merge(at(b)).map(|()| dep.version.to_string()) + }; + // The intersection, in its conventional spelling. + assert_eq!(merged("1.5", "1.7").unwrap(), "^1.7.0"); + assert_eq!(merged("1.7", "1.5").unwrap(), "^1.7.0"); + assert_eq!(merged("1", "1.2.3").unwrap(), "^1.2.3"); + assert_eq!(merged("0.4", "0.4.2").unwrap(), "^0.4.2"); + // A caret on 0.0.x allows one version, which is a point. + assert_eq!(merged("0.0.3", ">=0.0.3").unwrap(), "=0.0.3"); + assert_eq!(merged("=1.2.3", "1.2").unwrap(), "=1.2.3"); + assert_eq!(merged(">=1.0, <2", "1.5").unwrap(), "^1.5.0"); + assert_eq!(merged("~1.2", "1").unwrap(), "~1.2.0"); + assert_eq!(merged("1.*", "1.4").unwrap(), "^1.4.0"); + // A partial overlap is written as comparisons, nothing redundant. + assert_eq!(merged(">=1.2, <1.8", ">=1.5").unwrap(), ">=1.5.0, <1.8.0"); + // `<=1.8` allows every 1.8 patch, which is `<1.9.0` exactly. + assert_eq!(merged(">=1.2", "<=1.8").unwrap(), ">=1.2.0, <1.9.0"); + // `>1.2` allows nothing in 1.2, which is `>=1.3.0` exactly. + assert_eq!(merged(">1.2", ">1.1").unwrap(), ">=1.3.0"); + // Disjoint requirements conflict. + assert!(merged("1.7", "2.0").is_err()); + assert!(merged("0.4", "0.5").is_err()); + assert!(merged("=1.2.3", "=1.2.4").is_err()); + assert!(merged("<1.0", ">=1.0").is_err()); + + assert_eq!( + merged(">=1.0, <1.3", "1.1.1-alpha.1").unwrap(), + ">=1.1.1, <1.3.0" + ); + assert_eq!(merged(">=1.0, <1.2", "1.1.1-alpha.1").unwrap(), "~1.1.1"); + } + + #[test] + fn merge_refuses_different_versions() { + let mut dep = Dependency { + version: req("1.0"), + ..Dependency::new("uuid") + }; + let err = dep + .merge(Dependency { + version: req("2.0"), + ..Dependency::new("uuid") + }) + .unwrap_err(); + assert_eq!(err.ident, "uuid"); + assert!(err.reason.contains("version"), "{err}"); + } + + #[test] + fn merge_refuses_different_crates_under_one_identifier() { + let mut dep = Dependency { + rename: Some("js".to_string()), + ..Dependency::new("json-serde") + }; + let err = dep + .merge(Dependency { + rename: Some("js".to_string()), + ..Dependency::new("other-serde") + }) + .unwrap_err(); + assert_eq!(err.ident, "js"); + assert!(err.reason.contains("`json-serde`"), "{err}"); + } +} diff --git a/src/lib.rs b/src/lib.rs index acb085f..d52ad35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,23 @@ //! //! Within each [`Mod`], items are emitted in sort-key order and submodules //! in alphabetical order by name. +//! +//! ## Dependencies +//! +//! A [`Codespace`] may include the external dependencies its code needs, as +//! [`Dependency`] entries that a consumer can turn into Cargo.toml +//! `[dependencies]` lines: a crate name, an optional rename, a version +//! requirement, features, and whether default features are needed. A consumer +//! can register dependencies it emits with [`Codespace::add_dependency`]; +//! entries are kept by the identifier the code uses, so two versions of one +//! crate may coexist under two renames, and registrations under a given +//! identifier merge, with features unioned, `*` yielding to a real +//! requirement, and anything else that disagrees reported as a +//! [`DependencyConflict`] (see [`Codespace::dependencies`]). Nesting one +//! codespace inside another with [`Codespace::add_mod_from_codespace`] merges +//! dependencies. +//! +//! Codespace never infers dependencies from the code it holds. #![forbid(unsafe_code)] #![warn(missing_docs, missing_debug_implementations)] @@ -57,6 +74,10 @@ use std::{ use proc_macro2::{TokenStream, TokenTree}; use quote::{format_ident, quote}; +mod dependency; + +pub use dependency::{Dependency, DependencyConflict}; + /// Validate a module name segment, panicking with context on failure. /// /// `name` is the segment being validated; `full` is the complete path or @@ -132,6 +153,7 @@ impl Visibility { #[derive(Debug, Default)] pub struct Codespace { root: Mod, + dependencies: BTreeMap, } impl Codespace { @@ -183,12 +205,101 @@ impl Codespace { &mut self.root } - /// Convert into the root [`Mod`], consuming the `Codespace`. Useful when - /// inserting a `Codespace` into another `Codespace`. + /// Convert into the root [`Mod`], consuming the `Codespace`. + /// + /// To insert a `Codespace` as a mod, use + /// [`Codespace::add_mod_from_codespace`]. pub fn into_root_mod(self) -> Mod { self.root } + /// Register a dependency the generated code needs. + /// + /// Entries are kept by the identifier the code uses for the crate (see + /// [`Dependency::ident`]), which is the key of the crate's + /// `[dependencies]` line, so two versions of one crate may coexist under + /// two renames. A second registration under the same identifier merges + /// into the first. See [`Dependency`]. + pub fn add_dependency(&mut self, dep: Dependency) -> Result<(), DependencyConflict> { + match self.dependencies.entry(dep.ident()) { + Entry::Occupied(e) => e.into_mut().merge(dep), + Entry::Vacant(e) => { + e.insert(dep); + Ok(()) + } + } + } + + /// Registered dependencies, in identifier order. + pub fn dependencies(&self) -> impl Iterator { + self.dependencies.values() + } + + /// The `[dependencies]` section of a `Cargo.toml` for the generated + /// code: the header, then one line per registered dependency in + /// identifier order, as [`Dependency::to_toml_inline`] writes it, + /// ending in a newline. Empty when nothing is registered. + pub fn to_toml_dependencies(&self) -> String { + if self.dependencies.is_empty() { + return String::new(); + } + let mut out = String::from("[dependencies]\n"); + for dep in self.dependencies.values() { + out.push_str(&dep.to_toml_inline()); + out.push('\n'); + } + out + } + + /// Nest `other` as the submodule at `path` and take on its + /// dependencies. + /// + /// `path` is a `"::"` delimited string naming the submodule, such as + /// `"types"` or `"api::types"`; every segment is a module name, and + /// intermediate modules are created as needed. The module is added as + /// [`Mod::add_mod`] adds it, merging into an existing submodule of that + /// name, and the returned reference lets the caller set its docs, + /// attributes, and visibility. The dependencies merge as + /// [`Codespace::add_dependency`] merges them; on a conflict nothing is + /// changed. + /// + /// # Panics + /// + /// Panics if any segment of `path` is not a valid Rust identifier. + pub fn add_mod_from_codespace( + &mut self, + path: impl Into, + other: Codespace, + ) -> Result<&mut Mod, DependencyConflict> { + let deps = other.dependencies.into_values(); + let m = other.root; + let mut merged = self.dependencies.clone(); + for dep in deps { + match merged.entry(dep.ident()) { + Entry::Occupied(e) => e.into_mut().merge(dep)?, + Entry::Vacant(e) => { + e.insert(dep); + } + } + } + self.dependencies = merged; + + let path = path.into(); + let mut segs = path.split("::").peekable(); + let mut parent = &mut self.root; + loop { + let seg = segs.next().expect("path must not be empty"); + // Validate here (rather than via get_mod) so the panic message + // includes the full path the caller passed. + validate_mod_name(seg, &path); + if segs.peek().is_none() { + parent.add_mod(seg, m); + return Ok(parent.get_mod(seg)); + } + parent = parent.mods.entry(seg.to_string()).or_default(); + } + } + /// Consume the [`Codespace`] and render it into a [`TokenStream`]. /// /// The root module's contents are emitted flat. Each submodule is wrapped @@ -1077,4 +1188,164 @@ mod tests { .starts_with(r#"#![doc="Generatedcode."]#![doc="Donotedit."]#![allow(clippy::all)]"#)); assert!(out.contains("pubstructFoo")); } + + fn names<'a>(deps: &[&'a Dependency]) -> Vec<&'a str> { + deps.iter().map(|x| x.name.as_str()).collect() + } + + #[test] + fn registrations_are_answered_in_name_order() { + let mut cs = Codespace::default(); + cs.add_dependency(Dependency { + version: "1.0".parse().unwrap(), + features: vec!["derive".to_string()], + ..Dependency::new("serde") + }) + .unwrap(); + let deps = cs.dependencies().collect::>(); + assert_eq!(names(&deps), ["serde"]); + assert_eq!(deps[0].features, ["derive"]); + } + + #[test] + fn registrations_merge_or_conflict() { + let mut cs = Codespace::default(); + cs.add_dependency(Dependency { + features: vec!["serde".to_string()], + ..Dependency::new("uuid") + }) + .unwrap(); + cs.add_dependency(Dependency { + version: "1.0".parse().unwrap(), + features: vec!["v4".to_string()], + ..Dependency::new("uuid") + }) + .unwrap(); + let err = cs + .add_dependency(Dependency { + version: "2.0".parse().unwrap(), + ..Dependency::new("uuid") + }) + .unwrap_err(); + assert_eq!(err.ident, "uuid"); + let dep = cs.dependencies().next().unwrap(); + assert_eq!(dep.features, ["serde", "v4"]); + assert_eq!(dep.version.to_string(), "^1.0"); + } + + #[test] + fn toml_dependencies_is_the_whole_section() { + let mut cs = Codespace::default(); + assert_eq!(cs.to_toml_dependencies(), ""); + cs.add_dependency(Dependency { + version: "1.0".parse().unwrap(), + features: vec!["serde".to_string(), "v4".to_string()], + ..Dependency::new("uuid") + }) + .unwrap(); + cs.add_dependency(Dependency { + version: "0.4".parse().unwrap(), + ..Dependency::new("chrono") + }) + .unwrap(); + assert_eq!( + cs.to_toml_dependencies(), + "[dependencies]\nchrono = \"0.4\"\nuuid = { version = \"1.0\", features = [\"serde\", \"v4\"] }\n" + ); + } + + #[test] + fn two_versions_of_one_crate_live_under_two_identifiers() { + let mut cs = Codespace::default(); + cs.add_dependency(Dependency { + rename: Some("schemars08".to_string()), + version: "0.8".parse().unwrap(), + ..Dependency::new("schemars") + }) + .unwrap(); + cs.add_dependency(Dependency { + rename: Some("schemars1".to_string()), + version: "1.0".parse().unwrap(), + ..Dependency::new("schemars") + }) + .unwrap(); + let idents = cs.dependencies().map(Dependency::ident).collect::>(); + assert_eq!(idents, ["schemars08", "schemars1"]); + assert!(cs.dependencies().all(|dep| dep.name == "schemars")); + } + + #[test] + fn add_mod_from_codespace_nests_and_carries_dependencies() { + let mut inner = Codespace::default(); + inner.add_item("A", quote! { pub struct A(::chrono::NaiveDate); }); + inner.add_dependency(Dependency::new("chrono")).unwrap(); + inner.add_dependency(Dependency::new("json-serde")).unwrap(); + + let mut outer = Codespace::default(); + outer.add_item("Client", quote! { pub struct Client(::reqwest::Client); }); + outer.add_dependency(Dependency::new("reqwest")).unwrap(); + let types = outer.add_mod_from_codespace("types", inner).unwrap(); + types.add_docs("Generated types."); + + assert_eq!( + names(&outer.dependencies().collect::>()), + ["chrono", "json-serde", "reqwest"] + ); + let out = no_ws(&outer.into_stream().to_string()); + assert!(out.contains(r#"#[doc="Generatedtypes."]pubmodtypes{pubstructA"#)); + } + + #[test] + fn add_mod_from_codespace_takes_a_path() { + let mut inner = Codespace::default(); + inner.add_item("A", quote! { pub struct A; }); + inner.add_dependency(Dependency::new("chrono")).unwrap(); + + let mut outer = Codespace::default(); + outer + .add_mod_from_codespace("api::types", inner) + .unwrap() + .add_docs("Nested two deep."); + + assert_eq!( + outer + .dependencies() + .map(|dep| dep.name.as_str()) + .collect::>(), + ["chrono"] + ); + let out = no_ws(&outer.into_stream().to_string()); + assert!(out.contains(r#"pubmodapi{#[doc="Nestedtwodeep."]pubmodtypes{pubstructA"#)); + } + + #[test] + #[should_panic(expected = "is not a valid Rust identifier")] + fn add_mod_from_codespace_rejects_a_bad_segment() { + let mut outer = Codespace::default(); + let _ = outer.add_mod_from_codespace("api::not valid", Codespace::default()); + } + + #[test] + fn add_mod_from_codespace_conflict_changes_nothing() { + let mut inner = Codespace::default(); + inner + .add_dependency(Dependency { + version: "2.0".parse().unwrap(), + ..Dependency::new("uuid") + }) + .unwrap(); + let mut outer = Codespace::default(); + outer + .add_dependency(Dependency { + version: "1.0".parse().unwrap(), + ..Dependency::new("uuid") + }) + .unwrap(); + outer.add_mod_from_codespace("types", inner).unwrap_err(); + assert!(!outer.get_root_mod().has_mod("types")); + assert_eq!( + outer.dependencies().next().unwrap().version.to_string(), + "^1.0" + ); + } }