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    let pkg_json = fs::read_to_string(root.join("package.json"))?;
205    let v: serde_json::Value = serde_json::from_str(&pkg_json)?;
206    Ok(v["version"]
207        .as_str()
208        .context("No version in package.json")?
209        .to_string())
210}
211
212fn bump_version(current: &str, bump: &str) -> Result<String> {
213    let parts: Vec<&str> = current.split('.').collect();
214    if parts.len() != 3 {
215        return Err(anyhow::anyhow!("Invalid version format: {current}"));
216    }
217
218    let mut major: u32 = parts[0].parse()?;
219    let mut minor: u32 = parts[1].parse()?;
220    let mut patch: u32 = parts[2].parse()?;
221
222    match bump {
223        "major" => {
224            major += 1;
225            minor = 0;
226            patch = 0;
227        }
228        "minor" => {
229            minor += 1;
230            patch = 0;
231        }
232        _ => {
233            patch += 1;
234        }
235    }
236
237    Ok(format!("{major}.{minor}.{patch}"))
238}
239
240fn update_manifests(root: &Path, old_version: &str, new_version: &str) -> Result<()> {
241    // 1. package.json
242    let pkg_path = root.join("package.json");
243    let pkg_content = fs::read_to_string(&pkg_path)?;
244    let new_pkg = pkg_content.replace(
245        &format!("\"version\": \"{old_version}\""),
246        &format!("\"version\": \"{new_version}\""),
247    );
248    fs::write(pkg_path, new_pkg)?;
249
250    // 2. pyproject.toml
251    let py_path = root.join("pyproject.toml");
252    let py_content = fs::read_to_string(&py_path)?;
253    let new_py = py_content.replace(
254        &format!("version = \"{old_version}\""),
255        &format!("version = \"{new_version}\""),
256    );
257    fs::write(py_path, new_py)?;
258
259    // 3. Cargo.toml (Workspace)
260    let cargo_path = root.join("Cargo.toml");
261    let cargo_content = fs::read_to_string(&cargo_path)?;
262    let new_cargo = cargo_content.replace(
263        &format!("version = \"{old_version}\""),
264        &format!("version = \"{new_version}\""),
265    );
266    fs::write(cargo_path, new_cargo)?;
267
268    // 4. Directory.Build.props
269    let props_path = root.join("Directory.Build.props");
270    if props_path.exists() {
271        let props_content = fs::read_to_string(&props_path)?;
272        let new_props = props_content.replace(
273            &format!("<Version>{old_version}</Version>"),
274            &format!("<Version>{new_version}</Version>"),
275        );
276        fs::write(props_path, new_props)?;
277    }
278
279    Ok(())
280}
281
282fn update_changelog(root: &Path, version: &str, messages: &[String]) -> Result<()> {
283    let path = root.join("CHANGELOG.md");
284    let date = Utc::now().format("%Y-%m-%d");
285    let mut new_entry = format!("\n## [{version}] - {date}\n\n");
286    for msg in messages {
287        new_entry.push_str(&format!("- {msg}\n"));
288    }
289
290    if path.exists() {
291        let content = fs::read_to_string(&path)?;
292        let updated = format!(
293            "# Changelog\n{}{}",
294            new_entry,
295            content.replace("# Changelog", "")
296        );
297        fs::write(path, updated)?;
298    } else {
299        fs::write(path, format!("# Changelog\n{new_entry}"))?;
300    }
301    Ok(())
302}