1use std::fs;
2use std::path::{Path, PathBuf};
3
4use callisto_manifests::{open, OpenContext, WorkspaceCargoResolver};
5use callisto_model::{CommandError, CommandRunner, LockfileRefreshResult, ManifestRole};
6
7use crate::cascade::DepWriteTarget;
8use crate::error::GraphError;
9use crate::plan::{VersionPlan, VersionWriteTarget};
10
11#[derive(Clone, Debug, Default)]
12pub struct ApplyOptions {
13 pub refresh_lockfiles: bool,
14 pub transient: bool,
15}
16
17#[derive(Clone, Debug, Default)]
18pub struct ApplyOutcome {
19 pub lockfile_refresh_results: Option<Vec<LockfileRefreshResult>>,
20 pub staged: Vec<PathBuf>,
21}
22
23pub fn apply_version_plan<R: CommandRunner>(
24 root: &Path,
25 plan: &VersionPlan,
26 runner: &R,
27 opts: &ApplyOptions,
28) -> Result<ApplyOutcome, GraphError> {
29 let mut outcome = ApplyOutcome::default();
30 let mut modified_paths = Vec::new();
31
32 let cargo_workspace = if root.join("Cargo.toml").exists() {
33 if let Ok(resolver) = WorkspaceCargoResolver::load(&root.join("Cargo.toml")) {
34 resolver.inheritance().ok().map(std::sync::Arc::new)
35 } else {
36 None
37 }
38 } else {
39 None
40 };
41
42 let npm_workspace_kind = callisto_manifests::detect_npm_workspace_kind(root)
43 .ok()
44 .flatten();
45
46 let ctx = OpenContext {
47 workspace_root: root,
48 cargo_workspace,
49 npm_workspace_kind,
50 };
51
52 if !opts.transient {
53 for bump in &plan.bumps {
54 for write in &bump.writes {
55 match write {
56 VersionWriteTarget::Manifest(p) => {
57 let fmt = callisto_model::ManifestFormat::from_path(p)?;
58 let decl = callisto_model::ManifestDecl::new(
59 p.clone(),
60 ManifestRole::Canonical,
61 fmt,
62 )?;
63 let mut handle = open(&decl, &ctx)?;
64 handle.write_version(&bump.to)?;
65 modified_paths.push(p.clone());
66 }
67 VersionWriteTarget::CargoWorkspacePackage { root_manifest } => {
68 let mut ws_res = WorkspaceCargoResolver::load(&root.join(root_manifest))?;
69 ws_res.write_version(&bump.to)?;
70 modified_paths.push(root_manifest.clone());
71 }
72 }
73 }
74 }
75
76 for rewrite in &plan.rewrites {
77 match &rewrite.key.target {
78 DepWriteTarget::Manifest(p) => {
79 let fmt = callisto_model::ManifestFormat::from_path(p)?;
80 let decl =
81 callisto_model::ManifestDecl::new(p.clone(), ManifestRole::Canonical, fmt)?;
82 let mut handle = open(&decl, &ctx)?;
83 handle.update_dependency_spec(
84 &rewrite.key.name,
85 rewrite.key.kind.unwrap_or(callisto_model::DepKind::Runtime),
86 rewrite.to.clone(),
87 )?;
88 modified_paths.push(p.clone());
89 }
90 DepWriteTarget::CargoWorkspaceDependency { root_manifest } => {
91 let mut ws_res = WorkspaceCargoResolver::load(&root.join(root_manifest))?;
92 ws_res.write_dependency(&rewrite.key.name, rewrite.to.clone())?;
93 modified_paths.push(root_manifest.clone());
94 }
95 }
96 }
97
98 for cl in &plan.changelog_writes {
99 let rendered = callisto_changelog::render_section(&cl.input)?;
100 callisto_changelog::prepend(
101 root,
102 &cl.changelog_path,
103 &cl.input.package.display_name(),
104 &rendered,
105 )?;
106 modified_paths.push(cl.changelog_path.clone());
107 }
108
109 for cs_path in &plan.consumed_changesets {
110 let full = root.join(cs_path);
111 if full.exists() {
112 fs::remove_file(&full).map_err(|e| {
113 GraphError::Command(CommandError::Io {
114 program: "fs".to_string(),
115 message: e.to_string(),
116 })
117 })?;
118 modified_paths.push(cs_path.clone());
119 }
120 }
121
122 if let Some(ref pre_state) = plan.pre_state_update {
123 let default_dir = PathBuf::from(".changeset");
124 let pre_dir = plan
125 .consumed_changesets
126 .first()
127 .and_then(|p| p.parent())
128 .unwrap_or(&default_dir);
129 let rel_pre_path = pre_dir.join("pre.json");
130 let pre_path = root.join(&rel_pre_path);
131 let text = callisto_format::write_pre_json(pre_state);
132 callisto_manifests::atomic::atomic_write(&pre_path, &text).map_err(|e| {
133 GraphError::Command(CommandError::Io {
134 program: "fs".to_string(),
135 message: e.to_string(),
136 })
137 })?;
138 modified_paths.push(rel_pre_path);
139 } else if plan.delete_pre_json {
140 let rel_pre_path = PathBuf::from(".changeset/pre.json");
141 let pre_path = root.join(&rel_pre_path);
142 if pre_path.exists() {
143 fs::remove_file(&pre_path).map_err(|e| {
144 GraphError::Command(CommandError::Io {
145 program: "fs".to_string(),
146 message: e.to_string(),
147 })
148 })?;
149 modified_paths.push(rel_pre_path);
150 }
151 }
152
153 for lockfile in &[
155 "Cargo.lock",
156 "package-lock.json",
157 "pnpm-lock.yaml",
158 "yarn.lock",
159 "bun.lockb",
160 ] {
161 let p = PathBuf::from(lockfile);
162 if root.join(&p).exists() && !modified_paths.contains(&p) {
163 modified_paths.push(p);
164 }
165 }
166 }
167
168 if !opts.transient && !modified_paths.is_empty() {
169 let (existing, deleted): (Vec<_>, Vec<_>) =
170 modified_paths.iter().partition(|p| root.join(p).exists());
171
172 if !existing.is_empty() {
173 let mut args = vec!["add", "--"];
174 let strs: Vec<String> = existing.iter().map(|p| p.display().to_string()).collect();
175 for s in &strs {
176 args.push(s);
177 }
178 drop(runner.run("git", &args, root));
179 }
180
181 if !deleted.is_empty() {
182 let mut args = vec!["rm", "--cached", "--ignore-unmatch", "--"];
183 let strs: Vec<String> = deleted.iter().map(|p| p.display().to_string()).collect();
184 for s in &strs {
185 args.push(s);
186 }
187 drop(runner.run("git", &args, root));
188 }
189
190 outcome.staged = modified_paths;
191 }
192
193 Ok(outcome)
194}