resq_cli/commands/
version.rs1use anyhow::{Context, Result};
20use chrono::Utc;
21use clap::{Args, Subcommand};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25#[derive(Args)]
27pub struct VersionArgs {
28 #[command(subcommand)]
30 pub command: VersionCommands,
31}
32
33#[derive(Subcommand)]
35pub enum VersionCommands {
36 Add(AddArgs),
38 Apply(ApplyArgs),
40 Check,
42}
43
44#[derive(Args)]
46pub struct AddArgs {
47 #[arg(short, long, default_value = "patch")]
49 pub bump: String,
50 #[arg(short, long)]
52 pub message: String,
53}
54
55#[derive(Args)]
57pub struct ApplyArgs {
58 #[arg(long)]
60 pub dry_run: bool,
61}
62
63pub fn run(args: VersionArgs) -> Result<()> {
65 match args.command {
66 VersionCommands::Add(add_args) => add_changeset(add_args),
67 VersionCommands::Apply(apply_args) => apply_versions(apply_args),
68 VersionCommands::Check => check_versions(),
69 }
70}
71
72fn add_changeset(args: AddArgs) -> Result<()> {
73 let root = get_repo_root()?;
74 let changeset_dir = root.join(".changesets");
75 fs::create_dir_all(&changeset_dir)?;
76
77 let timestamp = Utc::now().format("%Y%m%d%H%M%S");
78 let filename = format!("{}-{}.md", timestamp, args.bump);
79 let path = changeset_dir.join(filename);
80
81 let content = format!("---\nbump: {}\n---\n\n{}", args.bump, args.message);
82
83 fs::write(&path, content)?;
84 println!(
85 "✅ Created changeset: .changesets/{}",
86 path.file_name().unwrap().to_string_lossy()
87 );
88 Ok(())
89}
90
91fn apply_versions(args: ApplyArgs) -> Result<()> {
92 let root = get_repo_root()?;
93 let changeset_dir = root.join(".changesets");
94
95 if !changeset_dir.exists() {
96 println!("ℹ️ No changesets found. Nothing to apply.");
97 return Ok(());
98 }
99
100 let entries = fs::read_dir(&changeset_dir)?;
101 let mut bump_type = "patch"; let mut messages = Vec::new();
104 let mut files_to_delete = Vec::new();
105
106 for entry in entries {
107 let entry = entry?;
108 let path = entry.path();
109 if path.extension().is_some_and(|ext| ext == "md") {
110 let content = fs::read_to_string(&path)?;
111
112 if content.contains("bump: major") {
114 bump_type = "major";
115 } else if content.contains("bump: minor") && bump_type != "major" {
116 bump_type = "minor";
117 }
118
119 if let Some(msg) = content.split("---").last() {
121 messages.push(msg.trim().to_string());
122 }
123 files_to_delete.push(path);
124 }
125 }
126
127 if messages.is_empty() {
128 println!("ℹ️ No valid changesets found.");
129 return Ok(());
130 }
131
132 let current_version = get_current_version(&root)?;
134 let next_version = bump_version(¤t_version, bump_type)?;
135
136 println!("🚀 Bumping version: {current_version} -> {next_version} ({bump_type})");
137
138 if args.dry_run {
139 println!("🏃 DRY RUN: Would update all manifests to {next_version}");
140 return Ok(());
141 }
142
143 update_manifests(&root, ¤t_version, &next_version)?;
145
146 update_changelog(&root, &next_version, &messages)?;
148
149 for file in files_to_delete {
151 fs::remove_file(file)?;
152 }
153
154 println!("✨ Successfully synchronized all versions to {next_version}!");
155 Ok(())
156}
157
158fn check_versions() -> Result<()> {
159 let root = get_repo_root()?;
160 let version = get_current_version(&root)?;
161
162 let manifests = [
163 root.join("package.json"),
164 root.join("Cargo.toml"),
165 root.join("pyproject.toml"),
166 root.join("Directory.Build.props"),
167 ];
168
169 let mut out_of_sync = false;
170 for path in manifests {
171 if path.exists() {
172 let content = fs::read_to_string(&path)?;
173 if !content.contains(&version) {
174 println!(
175 "❌ Out of sync: {} (expected version {})",
176 path.display(),
177 version
178 );
179 out_of_sync = true;
180 }
181 }
182 }
183
184 if out_of_sync {
185 return Err(anyhow::anyhow!(
186 "Versions are out of sync. Run 'resq version apply' or fix manually."
187 ));
188 }
189 println!("✅ All manifests are synchronized at version {version}");
190
191 Ok(())
192}
193
194fn get_repo_root() -> Result<PathBuf> {
195 let output = std::process::Command::new("git")
196 .arg("rev-parse")
197 .arg("--show-toplevel")
198 .output()?;
199 let path_str = String::from_utf8(output.stdout)?.trim().to_string();
200 Ok(PathBuf::from(path_str))
201}
202
203fn get_current_version(root: &Path) -> Result<String> {
204 let cargo_path = root.join("Cargo.toml");
206 if cargo_path.exists() {
207 let content = fs::read_to_string(&cargo_path)?;
208 for line in content.lines() {
209 if line.trim().starts_with("version = \"") {
210 let v = line
211 .split('"')
212 .nth(1)
213 .context("Invalid version line in Cargo.toml")?;
214 return Ok(v.to_string());
215 }
216 }
217 }
218
219 let pkg_path = root.join("package.json");
221 if pkg_path.exists() {
222 let pkg_json = fs::read_to_string(pkg_path)?;
223 let v: serde_json::Value = serde_json::from_str(&pkg_json)?;
224 if let Some(version) = v["version"].as_str() {
225 return Ok(version.to_string());
226 }
227 }
228
229 Err(anyhow::anyhow!(
230 "Could not find version in Cargo.toml or package.json"
231 ))
232}
233
234fn bump_version(current: &str, bump: &str) -> Result<String> {
235 let parts: Vec<&str> = current.split('.').collect();
236 if parts.len() < 3 {
237 return Err(anyhow::anyhow!("Invalid version format: {current}"));
238 }
239
240 let mut major: u32 = parts[0].parse()?;
241 let mut minor: u32 = parts[1].parse()?;
242 let mut patch: u32 = parts[2].parse()?;
243
244 match bump {
245 "major" => {
246 major += 1;
247 minor = 0;
248 patch = 0;
249 }
250 "minor" => {
251 minor += 1;
252 patch = 0;
253 }
254 _ => {
255 patch += 1;
256 }
257 }
258
259 Ok(format!("{major}.{minor}.{patch}"))
260}
261
262fn update_manifests(root: &Path, old_version: &str, new_version: &str) -> Result<()> {
263 let pkg_path = root.join("package.json");
265 if pkg_path.exists() {
266 let pkg_content = fs::read_to_string(&pkg_path)?;
267 let new_pkg = pkg_content.replace(
268 &format!("\"version\": \"{old_version}\""),
269 &format!("\"version\": \"{new_version}\""),
270 );
271 fs::write(pkg_path, new_pkg)?;
272 }
273
274 let py_path = root.join("pyproject.toml");
276 if py_path.exists() {
277 let py_content = fs::read_to_string(&py_path)?;
278 let new_py = py_content.replace(
279 &format!("version = \"{old_version}\""),
280 &format!("version = \"{new_version}\""),
281 );
282 fs::write(py_path, new_py)?;
283 }
284
285 let cargo_path = root.join("Cargo.toml");
287 if cargo_path.exists() {
288 let cargo_content = fs::read_to_string(&cargo_path)?;
289 let new_cargo = cargo_content.replace(
290 &format!("version = \"{old_version}\""),
291 &format!("version = \"{new_version}\""),
292 );
293 fs::write(cargo_path, new_cargo)?;
294 }
295
296 let props_path = root.join("Directory.Build.props");
298 if props_path.exists() {
299 let props_content = fs::read_to_string(&props_path)?;
300 let new_props = props_content.replace(
301 &format!("<Version>{old_version}</Version>"),
302 &format!("<Version>{new_version}</Version>"),
303 );
304 fs::write(props_path, new_props)?;
305 }
306
307 Ok(())
308}
309
310fn update_changelog(root: &Path, version: &str, messages: &[String]) -> Result<()> {
311 let path = root.join("CHANGELOG.md");
312 let date = Utc::now().format("%Y-%m-%d");
313 let mut new_entry = format!("\n## [{version}] - {date}\n\n");
314 for msg in messages {
315 new_entry.push_str(&format!("- {msg}\n"));
316 }
317
318 if path.exists() {
319 let content = fs::read_to_string(&path)?;
320 let updated = format!(
321 "# Changelog\n{}{}",
322 new_entry,
323 content.replace("# Changelog", "")
324 );
325 fs::write(path, updated)?;
326 } else {
327 fs::write(path, format!("# Changelog\n{new_entry}"))?;
328 }
329 Ok(())
330}