diff --git a/.gitignore b/.gitignore index f09af1c..c356f28 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ test_output/ # Config (for development) config.toml .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +**/agents/ +.scratch/ +CONTEXT.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..566ce6f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +**Important:** Use Chinese for information responses and thinking; use English for searching and querying. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. + +## Agent skills + +### Issue tracker + +Issues and specs live as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five canonical triage roles use their default label strings. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context repo with `CONTEXT.md` and `docs/adr/` at the root. See `docs/agents/domain.md`. diff --git a/src/commands/changelog.rs b/src/commands/changelog.rs index 897bd76..8595fd6 100644 --- a/src/commands/changelog.rs +++ b/src/commands/changelog.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::generator::ContentGenerator; +use crate::git::GitRepo; use crate::git::find_repo; use crate::git::{CommitInfo, changelog::*}; use crate::i18n::{Messages, translate_changelog_category}; @@ -120,7 +121,10 @@ impl ChangelogCommand { // Get commits println!("{}", messages.fetching_commits()); - let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?; + + // Determine from_tag: use explicit --from, or auto-detect from changelog + let from_tag = self.resolve_from_tag(&repo, &output_path, &messages); + let commits = generate_from_history(&repo, from_tag.as_deref(), Some(&self.to))?; if commits.is_empty() { bail!("{}", messages.no_commits_found()); @@ -167,33 +171,13 @@ impl ChangelogCommand { } } - // Write to file (always prepend to preserve history) + // Write to file (always prepend new entry before existing versions) if output_path.exists() { let existing = std::fs::read_to_string(&output_path)?; let new_content = if existing.is_empty() { format!("{}{}", CHANGELOG_HEADER, changelog) - } else if existing.starts_with(CHANGELOG_HEADER) { - format!("{}{}", CHANGELOG_HEADER, changelog) - } else if existing.starts_with("# Changelog") { - let lines: Vec<&str> = existing.lines().collect(); - let mut header_end = 0; - - for (i, line) in lines.iter().enumerate() { - if i == 0 && line.starts_with('#') { - header_end = i + 1; - } else if line.trim().is_empty() { - header_end = i + 1; - } else { - break; - } - } - - let header = lines[..header_end].join("\n"); - let rest = lines[header_end..].join("\n"); - - format!("{}\n{}\n{}", header, changelog, rest) } else { - format!("{}{}", CHANGELOG_HEADER, changelog) + insert_changelog_entry(&existing, &changelog) }; std::fs::write(&output_path, new_content)?; } else { @@ -206,6 +190,28 @@ impl ChangelogCommand { Ok(()) } + fn resolve_from_tag( + &self, + repo: &GitRepo, + output_path: &PathBuf, + messages: &Messages, + ) -> Option { + // Explicit --from always wins + if self.from.is_some() { + return self.from.clone(); + } + + // Auto-detect: find highest version already in changelog + let existing = std::fs::read_to_string(output_path).ok()?; + let versions = parse_changelog_versions(&existing); + let highest = versions.first()?; + + // Match highest version to a git tag + let tag = repo.find_tag_by_version(highest)?; + println!(" {}: {}", messages.version(), tag.name); + Some(tag.name) + } + async fn generate_with_ai( &self, version: &str, diff --git a/src/commands/tag.rs b/src/commands/tag.rs index f908f4d..c1af793 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -8,7 +8,8 @@ use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::generator::ContentGenerator; use crate::git::tag::{ - TagBuilder, VersionBump, bump_version, get_latest_version, suggest_version_bump, + TagBuilder, VersionBump, bump_version, get_latest_version, read_project_version, + suggest_version_bump, }; use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; @@ -63,6 +64,11 @@ pub struct TagCommand { /// Skip interactive prompts #[arg(short = 'y', long)] yes: bool, + + /// Auto-detect version from project config (Cargo.toml/pyproject.toml), + /// falling back to commit analysis with confirmation. Mutually exclusive with --bump. + #[arg(short = 'A', long, conflicts_with = "bump")] + auto: bool, } impl TagCommand { @@ -80,6 +86,9 @@ impl TagCommand { // Determine tag name let tag_name = if let Some(name) = &self.name { name.clone() + } else if self.auto { + self.auto_detect_version(&repo, &config.tag.version_prefix, &messages) + .await? } else if let Some(bump_str) = &self.bump { // Calculate bumped version let prefix = &config.tag.version_prefix; @@ -325,6 +334,57 @@ impl TagCommand { .await } + async fn auto_detect_version( + &self, + repo: &GitRepo, + prefix: &str, + messages: &Messages, + ) -> Result { + let project_dir = std::env::current_dir()?; + + // 1. Try reading from project config files + if let Some(version) = read_project_version(&project_dir) { + let tag_name = format!("{}{}", prefix, version); + println!( + "{} {}", + messages.latest_version(), + tag_name.cyan() + ); + return Ok(tag_name); + } + + // 2. Fall back to commit analysis + println!("{}", messages.auto_detect_bump()); + let commits = repo.get_commits(50)?; + let bump = suggest_version_bump(&commits); + let latest = get_latest_version(repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0)); + let version = bump_version(&latest, bump, None); + let tag_name = format!("{}{}", prefix, version); + + println!( + "{} {:?} → {}", + messages.suggested_bump(), + bump, + tag_name.cyan() + ); + + if !self.yes { + let confirm = Confirm::new() + .with_prompt(messages.use_this_version()) + .default(true) + .interact()?; + + if !confirm { + // Fall through to interactive version selection + return self + .select_version_interactive(repo, prefix, messages) + .await; + } + } + + Ok(tag_name) + } + fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result { let default_msg = format!("Release {}", version); diff --git a/src/git/changelog.rs b/src/git/changelog.rs index a148922..15089ee 100644 --- a/src/git/changelog.rs +++ b/src/git/changelog.rs @@ -409,8 +409,8 @@ pub fn generate_from_history( if let Some(from) = from_tag { repo.get_commits_between(from, to_ref) } else { - // Get last 50 commits if no tag specified - repo.get_commits(50) + // Get all commits from the beginning (no from_tag = initial changelog) + repo.get_commits(usize::MAX) } } @@ -476,3 +476,137 @@ impl ChangelogEntry { self } } + +/// Extract version strings from Keep a Changelog content. +/// Returns versions sorted by semver descending (highest first). +/// Excludes "Unreleased". +pub fn parse_changelog_versions(content: &str) -> Vec { + use semver::Version; + + let mut versions: Vec = vec![]; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("## [") { + if let Some(end) = trimmed.find(']') { + let version = &trimmed[4..end]; + if version != "Unreleased" && !version.is_empty() { + versions.push(version.to_string()); + } + } + } + } + // Sort by semver descending so highest version is first + versions.sort_by(|a, b| { + match (Version::parse(a), Version::parse(b)) { + (Ok(va), Ok(vb)) => vb.cmp(&va), + (Ok(_), Err(_)) => std::cmp::Ordering::Less, + (Err(_), Ok(_)) => std::cmp::Ordering::Greater, + (Err(_), Err(_)) => std::cmp::Ordering::Equal, + } + }); + versions +} + +/// Insert a new changelog entry into existing changelog content. +/// The new entry is placed after the header and before the first existing version section. +/// If no existing version sections are found, the new entry is appended after the header. +pub fn insert_changelog_entry(existing: &str, new_entry: &str) -> String { + // Find the first version section (## [x.y.z]) + if let Some(first_ver_pos) = existing.find("\n## [") { + let (header, rest) = existing.split_at(first_ver_pos); + // Ensure new_entry ends with a blank line before the next section + let entry = new_entry.trim_end(); + format!("{}\n{}\n{}", header.trim_end(), entry, rest) + } else { + // No existing version sections — append after header + format!("{}\n{}", existing.trim_end(), new_entry.trim_end()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_changelog_versions_extracts_versions() { + let content = "# Changelog\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; + let versions = parse_changelog_versions(content); + assert_eq!(versions, vec!["0.2.0", "0.1.0"]); + } + + #[test] + fn test_parse_changelog_versions_sorts_highest_first() { + // Versions in reversed order — should still return highest first + let content = "# Changelog\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n\n## [0.3.0] - 2024-04-01\n### Added\n- feature C\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n"; + let versions = parse_changelog_versions(content); + assert_eq!(versions, vec!["0.3.0", "0.2.0", "0.1.0"]); + } + + #[test] + fn test_parse_changelog_versions_excludes_unreleased() { + let content = "# Changelog\n\n## [Unreleased]\n### Added\n- wip\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; + let versions = parse_changelog_versions(content); + assert_eq!(versions, vec!["0.1.0"]); + } + + #[test] + fn test_parse_changelog_versions_empty_content() { + let versions = parse_changelog_versions(""); + assert!(versions.is_empty()); + } + + #[test] + fn test_parse_changelog_versions_no_versions() { + let content = "# Changelog\n\nSome description text.\n"; + let versions = parse_changelog_versions(content); + assert!(versions.is_empty()); + } + + #[test] + fn test_insert_changelog_entry_between_header_and_existing() { + let existing = "# Changelog\n\nAll notable changes...\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; + let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n"; + let result = insert_changelog_entry(existing, new_entry); + + // New entry should appear after header, before 0.1.0 + assert!(result.contains("## [0.2.0]")); + assert!(result.contains("## [0.1.0]")); + let pos_new = result.find("## [0.2.0]").unwrap(); + let pos_old = result.find("## [0.1.0]").unwrap(); + assert!(pos_new < pos_old, "new version should be before old version"); + } + + #[test] + fn test_insert_changelog_entry_preserves_all_existing_content() { + let existing = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; + let new_entry = "## [0.3.0] - 2024-04-01\n### Added\n- feature C\n"; + let result = insert_changelog_entry(existing, new_entry); + + assert!(result.contains("## [0.3.0]")); + assert!(result.contains("## [0.2.0]")); + assert!(result.contains("## [0.1.0]")); + assert!(result.contains("feature A"), "oldest content preserved"); + assert!(result.contains("feature B"), "middle content preserved"); + assert!(result.contains("feature C"), "new content present"); + } + + #[test] + fn test_insert_changelog_entry_empty_existing() { + let existing = "# Changelog\n\nAll notable changes...\n\n"; + let new_entry = "## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; + let result = insert_changelog_entry(existing, new_entry); + assert!(result.contains("## [0.1.0]")); + assert!(result.contains("# Changelog")); + } + + #[test] + fn test_insert_changelog_entry_extra_blank_lines_in_header() { + let existing = "# Changelog\n\n\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n"; + let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n"; + let result = insert_changelog_entry(existing, new_entry); + + let pos_new = result.find("## [0.2.0]").unwrap(); + let pos_old = result.find("## [0.1.0]").unwrap(); + assert!(pos_new < pos_old, "new version should be before old version"); + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs index 8ac4f34..d751dce 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -862,24 +862,34 @@ impl GitRepo { let name = String::from_utf8_lossy(name); let name = name.strip_prefix("refs/tags/").unwrap_or(&name); - if let Ok(commit) = self.repo.find_commit(oid) { - tags.push(TagInfo { - name: name.to_string(), - target: oid.to_string(), - message: commit.message().unwrap_or("").to_string(), - time: commit.time().seconds(), - }); + // Use find_object + peel_to_commit to handle both lightweight + // (oid is a commit) and annotated (oid is a tag object) tags. + if let Ok(obj) = self.repo.find_object(oid, None) { + if let Ok(commit) = obj.peel_to_commit() { + tags.push(TagInfo { + name: name.to_string(), + target: commit.id().to_string(), + message: commit.message().unwrap_or("").to_string(), + time: commit.time().seconds(), + }); + } } true })?; - // Sort tags by time (newest first) - tags.sort_by(|a, b| b.time.cmp(&a.time)); + // Sort tags by semver (descending), then by time + crate::git::tag::sort_tags_by_semver(&mut tags); Ok(tags) } + /// Find a tag whose version (stripping 'v' prefix) matches the given version string. + pub fn find_tag_by_version(&self, version: &str) -> Option { + let tags = self.get_tags().ok()?; + tags.into_iter().find(|t| t.version_name() == version) + } + /// Create a tag pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> { let head = self.repo.head()?; @@ -1071,6 +1081,13 @@ pub struct TagInfo { pub time: i64, } +impl TagInfo { + /// Return the version portion of the tag name (stripping leading 'v' if present). + pub fn version_name(&self) -> &str { + self.name.strip_prefix('v').unwrap_or(&self.name) + } +} + /// Repository status summary #[derive(Debug, Clone)] pub struct StatusSummary { @@ -1441,3 +1458,126 @@ pub struct ConfigDiff { pub left: String, pub right: String, } + +#[cfg(test)] +mod tests { + use super::*; + use git2::Signature; + use tempfile::TempDir; + + fn init_test_repo() -> (TempDir, GitRepo) { + let dir = TempDir::new().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + + // Configure git user + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test User").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + + // Create an initial commit (needed for tags) + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[]) + .unwrap(); + + let git_repo = GitRepo::open(dir.path()).unwrap(); + (dir, git_repo) + } + + #[test] + fn test_get_tags_returns_annotated_tags() { + let (_dir, repo) = init_test_repo(); + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let head = repo.repo.head().unwrap().peel_to_commit().unwrap(); + + // Create an annotated tag (what QuiCommit creates by default) + repo.repo + .tag("v0.2.0", head.as_object(), &sig, "Release v0.2.0", false) + .unwrap(); + + let tags = repo.get_tags().unwrap(); + let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); + + assert!( + tag_names.contains(&"v0.2.0"), + "annotated tag v0.2.0 should appear in tags list, got: {:?}", + tag_names + ); + } + + #[test] + fn test_get_tags_returns_lightweight_tags() { + let (_dir, repo) = init_test_repo(); + let head = repo.repo.head().unwrap().peel_to_commit().unwrap(); + + // Create a lightweight tag + repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false) + .unwrap(); + + let tags = repo.get_tags().unwrap(); + let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); + + assert!( + tag_names.contains(&"v0.1.0"), + "lightweight tag v0.1.0 should appear in tags list, got: {:?}", + tag_names + ); + } + + #[test] + fn test_get_tags_returns_mixed_annotated_and_lightweight() { + let (_dir, repo) = init_test_repo(); + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let head = repo.repo.head().unwrap().peel_to_commit().unwrap(); + + // Create annotated tag + repo.repo + .tag("v0.2.0", head.as_object(), &sig, "annotated", false) + .unwrap(); + + // Create lightweight tag + repo.repo + .tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false) + .unwrap(); + + let tags = repo.get_tags().unwrap(); + let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); + + assert!( + tag_names.contains(&"v0.2.0"), + "annotated tag should be present, got: {:?}", + tag_names + ); + assert!( + tag_names.contains(&"v0.1.0"), + "lightweight tag should be present, got: {:?}", + tag_names + ); + assert_eq!(tags.len(), 2, "should have exactly 2 tags"); + } + + #[test] + fn test_get_tags_target_points_to_commit_not_tag_object() { + let (_dir, repo) = init_test_repo(); + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let head = repo.repo.head().unwrap().peel_to_commit().unwrap(); + let expected_commit_id = head.id().to_string(); + + // Create annotated tag + repo.repo + .tag("v0.2.0", head.as_object(), &sig, "annotated", false) + .unwrap(); + + let tags = repo.get_tags().unwrap(); + let tag = tags.iter().find(|t| t.name == "v0.2.0").unwrap(); + + assert_eq!( + tag.target, expected_commit_id, + "tag.target should be the commit OID, not the tag object OID" + ); + } +} diff --git a/src/git/tag.rs b/src/git/tag.rs index 5cff9b1..db9444c 100644 --- a/src/git/tag.rs +++ b/src/git/tag.rs @@ -1,6 +1,7 @@ use super::GitRepo; use anyhow::{Result, bail}; use semver::Version; +use std::path::Path; /// Tag builder for creating tags pub struct TagBuilder { @@ -327,3 +328,193 @@ pub fn list_tags( Ok(filtered) } } + +/// Sort tags by semver version descending, then by time descending. +/// Non-semver tags are placed after semver tags, sorted by time descending. +pub fn sort_tags_by_semver(tags: &mut [super::TagInfo]) { + use semver::Version; + + tags.sort_by(|a, b| { + match ( + Version::parse(a.version_name()), + Version::parse(b.version_name()), + ) { + (Ok(va), Ok(vb)) => { + // Both semver: version descending, then time descending as tiebreaker + vb.cmp(&va).then_with(|| b.time.cmp(&a.time)) + } + (Ok(_), Err(_)) => std::cmp::Ordering::Less, // semver tags first + (Err(_), Ok(_)) => std::cmp::Ordering::Greater, // semver tags first + (Err(_), Err(_)) => b.time.cmp(&a.time), // both non-semver: time descending + } + }); +} + +/// Read the project version from Cargo.toml or pyproject.toml. +/// Returns None if no project config file is found or version cannot be read. +pub fn read_project_version(project_dir: &Path) -> Option { + // Try Cargo.toml first + let cargo_path = project_dir.join("Cargo.toml"); + if cargo_path.exists() { + if let Ok(content) = std::fs::read_to_string(&cargo_path) { + if let Ok(value) = content.parse::() { + if let Some(version) = value + .get("package") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + { + if let Ok(ver) = Version::parse(version) { + return Some(ver); + } + } + } + } + } + + // Try pyproject.toml + let pyproject_path = project_dir.join("pyproject.toml"); + if pyproject_path.exists() { + if let Ok(content) = std::fs::read_to_string(&pyproject_path) { + if let Ok(value) = content.parse::() { + if let Some(version) = value + .get("project") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + { + if let Ok(ver) = Version::parse(version) { + return Some(ver); + } + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::TagInfo; + + fn make_tag(name: &str, time: i64) -> TagInfo { + TagInfo { + name: name.to_string(), + target: "abc123".to_string(), + message: String::new(), + time, + } + } + + #[test] + fn test_sort_by_semver_descending() { + let mut tags = vec![ + make_tag("v1.0.0", 100), + make_tag("v2.0.0", 200), + make_tag("v1.5.0", 150), + ]; + sort_tags_by_semver(&mut tags); + let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["v2.0.0", "v1.5.0", "v1.0.0"]); + } + + #[test] + fn test_sort_semver_ties_broken_by_time() { + let mut tags = vec![ + make_tag("v1.0.0", 100), + make_tag("v1.0.0", 300), + make_tag("v1.0.0", 200), + ]; + sort_tags_by_semver(&mut tags); + let times: Vec = tags.iter().map(|t| t.time).collect(); + assert_eq!(times, vec![300, 200, 100]); + } + + #[test] + fn test_sort_non_semver_tags_at_end() { + let mut tags = vec![ + make_tag("release-2024", 400), + make_tag("v1.0.0", 100), + make_tag("staging", 300), + make_tag("v0.2.0", 200), + ]; + sort_tags_by_semver(&mut tags); + let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names[0], "v1.0.0"); + assert_eq!(names[1], "v0.2.0"); + assert_eq!(names[2], "release-2024"); + assert_eq!(names[3], "staging"); + } + + #[test] + fn test_sort_no_prefix_semver() { + let mut tags = vec![ + make_tag("1.0.0", 100), + make_tag("2.0.0", 200), + make_tag("0.1.0", 50), + ]; + sort_tags_by_semver(&mut tags); + let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["2.0.0", "1.0.0", "0.1.0"]); + } + + #[test] + fn test_sort_all_non_semver_by_time() { + let mut tags = vec![ + make_tag("release-2024", 100), + make_tag("release-2023", 300), + make_tag("beta", 200), + ]; + sort_tags_by_semver(&mut tags); + let times: Vec = tags.iter().map(|t| t.time).collect(); + assert_eq!(times, vec![300, 200, 100]); + } + + #[test] + fn test_read_project_version_from_cargo_toml() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", + ) + .unwrap(); + let version = read_project_version(dir.path()); + assert_eq!(version, Some(Version::new(0, 3, 0))); + } + + #[test] + fn test_read_project_version_from_pyproject_toml() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"test\"\nversion = \"0.2.1\"\n", + ) + .unwrap(); + let version = read_project_version(dir.path()); + assert_eq!(version, Some(Version::new(0, 2, 1))); + } + + #[test] + fn test_read_project_version_no_config_files() { + let dir = tempfile::TempDir::new().unwrap(); + let version = read_project_version(dir.path()); + assert_eq!(version, None); + } + + #[test] + fn test_read_project_version_cargo_takes_priority() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"test\"\nversion = \"0.3.0\"\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"test\"\nversion = \"0.2.1\"\n", + ) + .unwrap(); + let version = read_project_version(dir.path()); + assert_eq!(version, Some(Version::new(0, 3, 0))); + } +}