Skip to main content

resq_cli/commands/
version.rs

1/*
2 * Copyright 2026 ResQ
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! Version management command - Polyglot Changeset Implementation.
18
19use anyhow::{Context, Result};
20use chrono::Utc;
21use clap::{Args, Subcommand};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25/// Arguments for the version command.
26#[derive(Args)]
27pub struct VersionArgs {
28    /// Subcommand to execute.
29    #[command(subcommand)]
30    pub command: VersionCommands,
31}
32
33/// Commands for version management.
34#[derive(Subcommand)]
35pub enum VersionCommands {
36    /// Create a new changeset (intent to change version)
37    Add(AddArgs),
38    /// Consume changesets and apply version bumps across the monorepo
39    Apply(ApplyArgs),
40    /// Check if all versions are synchronized
41    Check,
42}
43
44/// Arguments for adding a changeset.
45#[derive(Args)]
46pub struct AddArgs {
47    /// Type of change (patch, minor, major)
48    #[arg(short, long, default_value = "patch")]
49    pub bump: String,
50    /// Summary of what changed
51    #[arg(short, long)]
52    pub message: String,
53}
54
55/// Arguments for applying version bumps.
56#[derive(Args)]
57pub struct ApplyArgs {
58    /// Dry run - see what would change without modifying files
59    #[arg(long)]
60    pub dry_run: bool,
61}
62
63/// Run the version command
64pub 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"; // Default
102
103    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            // Basic "frontmatter" parsing
113            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            // Extract message (everything after the second ---)
120            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    // Determine current version from root package.json
133    let current_version = get_current_version(&root)?;
134    let next_version = bump_version(&current_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    // Apply to all manifests
144    update_manifests(&root, &current_version, &next_version)?;
145
146    // Append to CHANGELOG.md
147    update_changelog(&root, &next_version, &messages)?;
148
149    // Cleanup changesets
150    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    // Try Cargo.toml first in this workspace
205    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    // Fallback to package.json
220    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    // 1. package.json
264    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    // 2. pyproject.toml
275    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    // 3. Cargo.toml (Workspace)
286    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    // 4. Directory.Build.props
297    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}