1use std::{
13 collections::{BTreeMap, BTreeSet},
14 fs,
15 path::{Component, Path, PathBuf},
16 sync::atomic::{AtomicUsize, Ordering},
17};
18
19use super::{
20 model::{KtDecl, KtFile},
21 validate::{Diagnostic, Severity, ValidationPolicy},
22};
23
24#[derive(Debug)]
26pub enum WriteKotlinError {
27 Io(std::io::Error),
28 Validation(Vec<Diagnostic>),
33 Other(String),
34}
35
36impl std::fmt::Display for WriteKotlinError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 WriteKotlinError::Io(e) => write!(f, "I/O error writing Kotlin file: {}", e),
40 WriteKotlinError::Validation(ds) => {
41 writeln!(f, "{} Kotlin validation error(s):", ds.len())?;
42 for d in ds {
43 writeln!(f, " {d}")?;
44 }
45 Ok(())
46 }
47 WriteKotlinError::Other(s) => write!(f, "Kotlin emission error: {}", s),
48 }
49 }
50}
51
52impl std::error::Error for WriteKotlinError {}
53
54impl From<std::io::Error> for WriteKotlinError {
55 fn from(e: std::io::Error) -> Self {
56 WriteKotlinError::Io(e)
57 }
58}
59
60pub fn merge_files(fragments: Vec<KtFile>) -> Result<Vec<KtFile>, WriteKotlinError> {
69 let (merged, _warnings) = merge_files_with(fragments, &ValidationPolicy::new())?;
70 Ok(merged)
71}
72
73pub fn merge_files_with(
76 fragments: Vec<KtFile>,
77 policy: &ValidationPolicy,
78) -> Result<(Vec<KtFile>, Vec<Diagnostic>), WriteKotlinError> {
79 let mut groups: BTreeMap<String, KtFile> = BTreeMap::new();
80 let mut raw_seen: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();
85 for frag in fragments {
86 let seen = raw_seen.entry(frag.package.clone()).or_default();
87 let merged = groups
88 .entry(frag.package.clone())
89 .or_insert_with(|| KtFile::new(frag.package.clone()));
90 for decl in frag.decls {
91 if let KtDecl::Raw { name, code } = &decl {
92 let mut rendered = String::new();
93 code.render(0, &mut rendered);
94 if !seen.insert((name.clone(), rendered)) {
95 continue;
96 }
97 }
98 merged.decls.push(decl);
99 }
100 merged.extra_imports.extend(frag.extra_imports);
101 if merged.banner.is_none() {
105 merged.banner = frag.banner;
106 }
107 }
108 let merged: Vec<KtFile> = groups.into_values().collect();
109 let (errors, warnings) = split_diagnostics(&merged, policy);
110 if errors.is_empty() {
111 Ok((merged, warnings))
112 } else {
113 Err(WriteKotlinError::Validation(errors))
114 }
115}
116
117fn split_diagnostics(
120 files: &[KtFile],
121 policy: &ValidationPolicy,
122) -> (Vec<Diagnostic>, Vec<Diagnostic>) {
123 files
124 .iter()
125 .flat_map(|f| f.validate_with(policy))
126 .partition(|d| d.severity == Severity::Error)
127}
128
129pub fn merged_file_path(kotlin_root: &Path, file: &KtFile, fallback_name: &str) -> PathBuf {
136 if file.package.is_empty() {
137 kotlin_root.join(format!("{fallback_name}.kt"))
138 } else {
139 kotlin_root.join(format!("{}.kt", file.package.replace('.', "/")))
140 }
141}
142
143const OWNERSHIP_MARKER: &str = ".kotlin-codegen-output";
144const OWNERSHIP_MARKER_CONTENT: &str = "kotlin-codegen output v1\n";
145
146pub fn write_files(files: &[KtFile], kotlin_root: &Path) -> Result<Vec<PathBuf>, WriteKotlinError> {
158 write_files_with(files, kotlin_root, &ValidationPolicy::new()).map(|(paths, _)| paths)
159}
160
161pub fn write_files_with(
164 files: &[KtFile],
165 kotlin_root: &Path,
166 policy: &ValidationPolicy,
167) -> Result<(Vec<PathBuf>, Vec<Diagnostic>), WriteKotlinError> {
168 let (errors, warnings) = split_diagnostics(files, policy);
169 if !errors.is_empty() {
170 return Err(WriteKotlinError::Validation(errors));
171 }
172 write_validated(files, kotlin_root).map(|paths| (paths, warnings))
173}
174
175fn write_validated(files: &[KtFile], kotlin_root: &Path) -> Result<Vec<PathBuf>, WriteKotlinError> {
176 let root_state = inspect_root(kotlin_root)?;
177 let parent = kotlin_root.parent().unwrap_or_else(|| Path::new("."));
178 fs::create_dir_all(parent)?;
179 let staging = unique_sibling_path(kotlin_root, "staging");
180 fs::create_dir(&staging)?;
181
182 let result = write_staging(files, &staging).and_then(|relative_paths| {
183 replace_root(kotlin_root, root_state, &staging)?;
184 Ok(relative_paths
185 .into_iter()
186 .map(|path| kotlin_root.join(path))
187 .collect())
188 });
189 if result.is_err() {
190 let _ = fs::remove_dir_all(&staging);
191 }
192 result
193}
194
195#[derive(Clone, Copy)]
196enum RootState {
197 Missing,
198 Empty,
199 Owned,
200}
201
202fn inspect_root(kotlin_root: &Path) -> Result<RootState, WriteKotlinError> {
203 let metadata = match fs::symlink_metadata(kotlin_root) {
204 Ok(metadata) => metadata,
205 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
206 return Ok(RootState::Missing)
207 }
208 Err(error) => return Err(error.into()),
209 };
210 if metadata.file_type().is_symlink() || !metadata.is_dir() {
211 return Err(WriteKotlinError::Other(format!(
212 "Kotlin output root `{}` must be a directory",
213 kotlin_root.display()
214 )));
215 }
216 if fs::read_dir(kotlin_root)?.next().is_none() {
217 return Ok(RootState::Empty);
218 }
219
220 let marker = kotlin_root.join(OWNERSHIP_MARKER);
221 let marker_metadata = match fs::symlink_metadata(&marker) {
222 Ok(metadata) => metadata,
223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
224 return Err(WriteKotlinError::Other(format!(
225 "refusing to replace non-empty Kotlin output root `{}` without an ownership marker",
226 kotlin_root.display()
227 )));
228 }
229 Err(error) => return Err(error.into()),
230 };
231 if marker_metadata.file_type().is_symlink() || !marker_metadata.is_file() {
232 return Err(WriteKotlinError::Other(format!(
233 "refusing to replace non-empty Kotlin output root `{}` without an ownership marker",
234 kotlin_root.display()
235 )));
236 }
237 if fs::read_to_string(&marker)?.trim() != OWNERSHIP_MARKER_CONTENT.trim() {
242 return Err(WriteKotlinError::Other(format!(
243 "refusing to replace non-empty Kotlin output root `{}` without an ownership marker",
244 kotlin_root.display()
245 )));
246 }
247 Ok(RootState::Owned)
248}
249
250fn write_staging(files: &[KtFile], staging: &Path) -> Result<Vec<PathBuf>, WriteKotlinError> {
251 fs::write(staging.join(OWNERSHIP_MARKER), OWNERSHIP_MARKER_CONTENT)?;
252 let mut written = Vec::new();
253 for file in files {
254 let fallback = file
255 .decls
256 .first()
257 .map(|decl| decl.name().to_string())
258 .unwrap_or_else(|| "Generated".to_string());
259 let relative_path = merged_file_path(Path::new(""), file, &fallback);
260 ensure_relative_output_path(&relative_path)?;
261 let path = staging.join(&relative_path);
262 if let Some(parent) = path.parent() {
263 fs::create_dir_all(parent)?;
264 }
265 fs::write(&path, file.render())?;
266 written.push(relative_path);
267 }
268 Ok(written)
269}
270
271fn ensure_relative_output_path(path: &Path) -> Result<(), WriteKotlinError> {
272 if path.components().any(|component| {
273 matches!(
274 component,
275 Component::RootDir | Component::Prefix(_) | Component::ParentDir
276 )
277 }) {
278 return Err(WriteKotlinError::Other(format!(
279 "Kotlin output path `{}` escapes the output root",
280 path.display()
281 )));
282 }
283 Ok(())
284}
285
286fn replace_root(
287 kotlin_root: &Path,
288 root_state: RootState,
289 staging: &Path,
290) -> Result<(), WriteKotlinError> {
291 match root_state {
292 RootState::Missing => fs::rename(staging, kotlin_root)?,
293 RootState::Empty => {
294 fs::remove_dir(kotlin_root)?;
295 fs::rename(staging, kotlin_root)?;
296 }
297 RootState::Owned => {
298 let backup = unique_sibling_path(kotlin_root, "previous");
299 fs::rename(kotlin_root, &backup)?;
300 if let Err(error) = fs::rename(staging, kotlin_root) {
301 let _ = fs::rename(&backup, kotlin_root);
302 return Err(error.into());
303 }
304 fs::remove_dir_all(backup)?;
305 }
306 }
307 Ok(())
308}
309
310fn unique_sibling_path(kotlin_root: &Path, purpose: &str) -> PathBuf {
311 static SEQUENCE: AtomicUsize = AtomicUsize::new(0);
312 let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
313 let name = kotlin_root
314 .file_name()
315 .and_then(|name| name.to_str())
316 .unwrap_or("kotlin");
317 kotlin_root.with_file_name(format!(
318 ".{name}.kotlin-codegen-{purpose}-{}_{}",
319 std::process::id(),
320 sequence
321 ))
322}