1use crate::literal::{parse_literal, split_top_level, LiteralValue};
2use crate::{PrayError, PrayResult};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct PackageUpstream {
9 pub name: String,
10 pub constraint: String,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct LockedUpstream {
15 pub name: String,
16 pub version: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub source: Option<String>,
19 pub tree_hash: String,
20 pub artifact_hash: String,
21}
22
23pub fn parse_upstream(rest: &str) -> PrayResult<PackageUpstream> {
24 let mut positional = Vec::new();
25 for segment in split_top_level(rest.trim().trim_end_matches(','), ',') {
26 if !segment.is_empty() {
27 positional.push(parse_literal(&segment)?);
28 }
29 }
30 let name = string_from_value(positional.first().ok_or_else(|| PrayError::Parse {
31 kind: "prayspec",
32 message: "missing upstream name".to_string(),
33 })?)?;
34 let constraint = positional
35 .get(1)
36 .map(string_from_value)
37 .transpose()?
38 .unwrap_or_else(|| "*".to_string());
39 if name.is_empty() {
40 return Err(PrayError::Parse {
41 kind: "prayspec",
42 message: "missing upstream name".to_string(),
43 });
44 }
45 Ok(PackageUpstream { name, constraint })
46}
47
48pub fn is_identity_path(path: &str) -> bool {
49 Path::new(path).extension().and_then(|value| value.to_str()) == Some("prayspec")
50}
51
52pub fn content_paths(files: &[String]) -> Vec<String> {
53 files
54 .iter()
55 .filter(|path| !is_identity_path(path))
56 .cloned()
57 .collect()
58}
59
60pub fn is_clean_replica(
61 old_content: &BTreeMap<String, Vec<u8>>,
62 local_content: &BTreeMap<String, Vec<u8>>,
63) -> bool {
64 local_content == old_content
65}
66
67#[path = "package_upstream_overlay.rs"]
68mod overlay;
69pub use overlay::{overlay_drift_line, overlay_file_changes, OverlayFileChange};
70
71pub fn merge_content_files(
72 old_content: &BTreeMap<String, Vec<u8>>,
73 new_content: &BTreeMap<String, Vec<u8>>,
74 local_content: &BTreeMap<String, Vec<u8>>,
75) -> PrayResult<BTreeMap<String, Vec<u8>>> {
76 match try_merge_content_files(old_content, new_content, local_content) {
77 Ok(merged) => Ok(merged),
78 Err(paths) => Err(PrayError::Resolution(format!(
79 "upstream merge conflict in {}",
80 paths.join(", ")
81 ))),
82 }
83}
84
85pub fn try_merge_content_files(
86 old_content: &BTreeMap<String, Vec<u8>>,
87 new_content: &BTreeMap<String, Vec<u8>>,
88 local_content: &BTreeMap<String, Vec<u8>>,
89) -> Result<BTreeMap<String, Vec<u8>>, Vec<String>> {
90 if is_clean_replica(old_content, local_content) {
91 return Ok(new_content.clone());
92 }
93 let mut paths = BTreeMap::new();
94 for path in old_content
95 .keys()
96 .chain(new_content.keys())
97 .chain(local_content.keys())
98 {
99 paths.insert(path.clone(), ());
100 }
101 let mut merged = BTreeMap::new();
102 let mut conflicts = Vec::new();
103 for path in paths.keys() {
104 let old = old_content.get(path);
105 let new = new_content.get(path);
106 let local = local_content.get(path);
107 match (old, new, local) {
108 (_, Some(new_bytes), Some(local_bytes)) if local_bytes == new_bytes => {
109 merged.insert(path.clone(), new_bytes.clone());
110 }
111 (Some(old_bytes), Some(new_bytes), Some(local_bytes)) if local_bytes == old_bytes => {
112 merged.insert(path.clone(), new_bytes.clone());
113 }
114 (Some(old_bytes), Some(new_bytes), Some(local_bytes)) if old_bytes == new_bytes => {
115 merged.insert(path.clone(), local_bytes.clone());
116 }
117 (Some(old_bytes), None, Some(local_bytes)) if local_bytes == old_bytes => {}
118 (Some(old_bytes), Some(new_bytes), None) if old_bytes == new_bytes => {}
119 (None, Some(new_bytes), None) => {
120 merged.insert(path.clone(), new_bytes.clone());
121 }
122 (None, None, Some(local_bytes)) => {
123 merged.insert(path.clone(), local_bytes.clone());
124 }
125 (Some(_), None, None) => {}
126 _ => {
127 conflicts.push(path.clone());
128 }
129 }
130 }
131 if conflicts.is_empty() {
132 Ok(merged)
133 } else {
134 Err(conflicts)
135 }
136}
137
138pub fn upstream_merge_conflict_message(
139 fork: &str,
140 upstream_name: &str,
141 old_version: &str,
142 new_version: &str,
143 paths: &[String],
144) -> String {
145 format!(
146 "upstream merge conflict in {fork} while refreshing {upstream_name} {old_version} to {new_version}: {}. \
147 Reset those content files to the locked upstream tree, run `pray update --latest`, \
148 re-apply intentional overlays, then bump the fork version (upstream_merge_conflict).",
149 paths.join(", ")
150 )
151}
152
153pub fn next_upstream_constraint(current: &str, new_version: &str) -> String {
154 let trimmed = current.trim();
155 if trimmed == "*"
156 || trimmed.starts_with("~>")
157 || trimmed.starts_with('^')
158 || trimmed.starts_with(">=")
159 || trimmed.starts_with('>')
160 || trimmed.starts_with("<=")
161 || trimmed.starts_with('<')
162 {
163 return current.to_string();
164 }
165 format!("= {new_version}")
166}
167
168fn string_from_value(value: &LiteralValue) -> PrayResult<String> {
169 value
170 .as_string()
171 .map(str::to_string)
172 .ok_or_else(|| PrayError::Parse {
173 kind: "prayspec",
174 message: format!("expected string-like literal, found {:?}", value),
175 })
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn try_merge_reports_would_conflict_paths_without_writing() {
184 let old = BTreeMap::from([("README.md".to_string(), b"old".to_vec())]);
185 let new = BTreeMap::from([("README.md".to_string(), b"new".to_vec())]);
186 let local = BTreeMap::from([("README.md".to_string(), b"brand".to_vec())]);
187 let paths = try_merge_content_files(&old, &new, &local).expect_err("conflict");
188 assert_eq!(paths, vec!["README.md".to_string()]);
189 }
190
191 #[test]
192 fn clean_replica_takes_new_tree() {
193 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
194 let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
195 let merged = merge_content_files(&old, &new, &old).expect("merge");
196 assert_eq!(
197 merged.get("exports/a.md").map(Vec::as_slice),
198 Some(&b"new"[..])
199 );
200 }
201
202 #[test]
203 fn local_edit_against_upstream_change_conflicts() {
204 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
205 let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
206 let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
207 let error = merge_content_files(&old, &new, &local).expect_err("conflict");
208 assert!(error.to_string().contains("exports/a.md"));
209 }
210
211 #[test]
212 fn merge_lists_every_conflicting_path() {
213 let old = BTreeMap::from([
214 ("README.md".to_string(), b"old readme".to_vec()),
215 ("exports/a.md".to_string(), b"old".to_vec()),
216 ]);
217 let new = BTreeMap::from([
218 ("README.md".to_string(), b"new readme".to_vec()),
219 ("exports/a.md".to_string(), b"new".to_vec()),
220 ]);
221 let local = BTreeMap::from([
222 ("README.md".to_string(), b"local readme".to_vec()),
223 ("exports/a.md".to_string(), b"edit".to_vec()),
224 ]);
225 let paths = try_merge_content_files(&old, &new, &local).expect_err("conflict");
226 assert!(paths.contains(&"README.md".to_string()));
227 assert!(paths.contains(&"exports/a.md".to_string()));
228 let message =
229 upstream_merge_conflict_message("fork/base", "sample/base", "1.4.3", "1.4.4", &paths);
230 assert!(message.contains("fork/base"));
231 assert!(message.contains("sample/base 1.4.3 to 1.4.4"));
232 assert!(message.contains("README.md"));
233 assert!(message.contains("exports/a.md"));
234 assert!(message.contains("upstream_merge_conflict"));
235 assert!(message.contains("pray update --latest"));
236 }
237
238 #[test]
239 fn local_edit_kept_when_upstream_unchanged() {
240 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
241 let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
242 let merged = merge_content_files(&old, &old, &local).expect("merge");
243 assert_eq!(
244 merged.get("exports/a.md").map(Vec::as_slice),
245 Some(&b"edit"[..])
246 );
247 }
248
249 #[test]
250 fn fork_deleted_file_conflicts_when_upstream_changes_it() {
251 let old = BTreeMap::from([("exports/drop.md".to_string(), b"old".to_vec())]);
252 let new = BTreeMap::from([("exports/drop.md".to_string(), b"new".to_vec())]);
253 let local = BTreeMap::new();
254 let paths = try_merge_content_files(&old, &new, &local).expect_err("conflict");
255 assert!(paths.contains(&"exports/drop.md".to_string()));
256 }
257
258 #[test]
259 fn both_sides_deleted_omits_path() {
260 let old = BTreeMap::from([("exports/drop.md".to_string(), b"old".to_vec())]);
261 let new = BTreeMap::new();
262 let local = BTreeMap::new();
263 let merged = merge_content_files(&old, &new, &local).expect("merge");
264 assert!(!merged.contains_key("exports/drop.md"));
265 }
266
267 #[test]
268 fn fork_deleted_stays_deleted_when_upstream_unchanged() {
269 let old = BTreeMap::from([("exports/drop.md".to_string(), b"old".to_vec())]);
270 let local = BTreeMap::new();
271 let merged = merge_content_files(&old, &old, &local).expect("merge");
272 assert!(!merged.contains_key("exports/drop.md"));
273 }
274
275 #[test]
276 fn exact_constraint_rewrites_to_new_version() {
277 assert_eq!(next_upstream_constraint("= 1.4.3", "1.4.4"), "= 1.4.4");
278 assert_eq!(next_upstream_constraint("1.4.3", "1.4.4"), "= 1.4.4");
279 assert_eq!(next_upstream_constraint("~> 1.4", "1.4.4"), "~> 1.4");
280 }
281}