use crate::config::Config;
use crate::error::{Error, Result};
use crate::model::Bump;
use rand::seq::SliceRandom;
use rand::Rng;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const INTENTS_PATH: &str = ".intentional/intents";
const ADJECTIVES: &[&str] = &[
"amber", "brisk", "calm", "clear", "gentle", "lucky", "quiet", "swift",
];
const NOUNS: &[&str] = &[
"badger", "comet", "falcon", "lantern", "otter", "panda", "river", "willow",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Intent {
pub id: String,
pub release_units: BTreeMap<String, Bump>,
pub message: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntentDraft {
pub release_units: BTreeMap<String, Bump>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntentWrite {
pub path: PathBuf,
pub contents: String,
}
impl Intent {
pub fn load_all(root: &Path, config: &Config) -> Result<Vec<Self>> {
let directory = root.join(INTENTS_PATH);
if !directory.exists() {
return Ok(Vec::new());
}
let mut paths = std::fs::read_dir(&directory)
.map_err(|error| Error::io(&directory, error))?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|extension| extension == "md"))
.collect::<Vec<_>>();
paths.sort();
paths
.into_iter()
.map(|path| Self::load(&path, config))
.collect()
}
pub fn load(path: &Path, config: &Config) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
Self::parse(path, &text, config)
}
pub fn parse(path: &Path, text: &str, config: &Config) -> Result<Self> {
let (frontmatter, message) = split_frontmatter(text)?;
let release_units: BTreeMap<String, Bump> = serde_yaml::from_str(frontmatter)?;
validate_draft(&release_units, message, config)?;
let id = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| {
Error::Validation(format!("invalid intent filename {}", path.display()))
})?
.to_owned();
Ok(Self {
id,
release_units,
message: message.trim().to_owned(),
path: path.to_owned(),
})
}
}
impl IntentDraft {
pub fn plan(self, root: &Path, config: &Config) -> Result<IntentWrite> {
validate_draft(&self.release_units, &self.message, config)?;
let mut rng = rand::thread_rng();
let adjective = ADJECTIVES
.choose(&mut rng)
.expect("adjectives are non-empty");
let noun = NOUNS.choose(&mut rng).expect("nouns are non-empty");
let suffix: u32 = rng.gen_range(0..=0xffff);
let slug = format!("{adjective}-{noun}-{suffix:04x}");
let relative = PathBuf::from(INTENTS_PATH).join(format!("{slug}.md"));
let path = root.join(&relative);
if path.exists() {
return Err(Error::Validation(format!(
"generated intent path already exists: {}",
relative.display()
)));
}
let yaml = serde_yaml::to_string(&self.release_units)?;
let contents = format!("---\n{yaml}---\n\n{}\n", self.message.trim());
Ok(IntentWrite {
path: relative,
contents,
})
}
}
impl IntentWrite {
pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
if dry_run {
return Ok(());
}
let path = root.join(&self.path);
let parent = path.parent().expect("intent path has a parent");
std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
std::fs::write(&path, &self.contents).map_err(|error| Error::io(&path, error))
}
}
fn validate_draft(
release_units: &BTreeMap<String, Bump>,
message: &str,
config: &Config,
) -> Result<()> {
if release_units.is_empty() {
return Err(Error::Validation(
"intent must reference at least one release unit".to_owned(),
));
}
if message.trim().is_empty() {
return Err(Error::Validation(
"intent changelog message must not be empty".to_owned(),
));
}
for (release_unit, bump) in release_units {
if !config.release_units.contains_key(release_unit) {
return Err(Error::Validation(format!(
"intent references unknown release unit {release_unit}"
)));
}
if *bump == Bump::None {
return Err(Error::Validation(format!(
"intent release unit {release_unit} must declare major, minor, or patch"
)));
}
}
Ok(())
}
fn split_frontmatter(text: &str) -> Result<(&str, &str)> {
let rest = text.strip_prefix("---\n").ok_or_else(|| {
Error::Validation("intent must start with YAML frontmatter delimiter".to_owned())
})?;
let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
Error::Validation("intent frontmatter is missing its closing delimiter".to_owned())
})?;
Ok((frontmatter, message))
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> Config {
Config::from_yaml(
r#"
contract: contract-1
release-units:
library:
path: .
projections:
- { adapter: npm, file: package.json, mode: committed }
tags:
primary: { role: primary, template: '{id}@{version}' }
"#,
)
.expect("valid config")
}
#[test]
fn plans_direct_frontmatter_mapping() {
let draft = IntentDraft {
release_units: BTreeMap::from([("library".to_owned(), Bump::Minor)]),
message: "Add a useful capability.".to_owned(),
};
let write = draft.plan(Path::new("."), &config()).expect("planned");
assert!(write.path.to_string_lossy().starts_with(INTENTS_PATH));
assert!(write.contents.starts_with("---\nlibrary: minor\n---"));
assert!(write.contents.contains("Add a useful capability."));
}
#[test]
fn rejects_unknown_release_unit() {
let draft = IntentDraft {
release_units: BTreeMap::from([("missing".to_owned(), Bump::Patch)]),
message: "Correct a defect.".to_owned(),
};
let error = draft
.plan(Path::new("."), &config())
.expect_err("unknown release unit rejected");
assert!(error.to_string().contains("unknown release unit missing"));
}
}