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
67pub fn merge_content_files(
68 old_content: &BTreeMap<String, Vec<u8>>,
69 new_content: &BTreeMap<String, Vec<u8>>,
70 local_content: &BTreeMap<String, Vec<u8>>,
71) -> PrayResult<BTreeMap<String, Vec<u8>>> {
72 match try_merge_content_files(old_content, new_content, local_content) {
73 Ok(merged) => Ok(merged),
74 Err(paths) => Err(PrayError::Resolution(format!(
75 "upstream merge conflict in {}",
76 paths.join(", ")
77 ))),
78 }
79}
80
81pub fn try_merge_content_files(
82 old_content: &BTreeMap<String, Vec<u8>>,
83 new_content: &BTreeMap<String, Vec<u8>>,
84 local_content: &BTreeMap<String, Vec<u8>>,
85) -> Result<BTreeMap<String, Vec<u8>>, Vec<String>> {
86 if is_clean_replica(old_content, local_content) {
87 return Ok(new_content.clone());
88 }
89 let mut paths = BTreeMap::new();
90 for path in old_content
91 .keys()
92 .chain(new_content.keys())
93 .chain(local_content.keys())
94 {
95 paths.insert(path.clone(), ());
96 }
97 let mut merged = BTreeMap::new();
98 let mut conflicts = Vec::new();
99 for path in paths.keys() {
100 let old = old_content.get(path);
101 let new = new_content.get(path);
102 let local = local_content.get(path);
103 match (old, new, local) {
104 (_, Some(new_bytes), Some(local_bytes)) if local_bytes == new_bytes => {
105 merged.insert(path.clone(), new_bytes.clone());
106 }
107 (Some(old_bytes), Some(new_bytes), Some(local_bytes)) if local_bytes == old_bytes => {
108 merged.insert(path.clone(), new_bytes.clone());
109 }
110 (Some(old_bytes), Some(new_bytes), Some(local_bytes)) if old_bytes == new_bytes => {
111 merged.insert(path.clone(), local_bytes.clone());
112 }
113 (Some(old_bytes), None, Some(local_bytes)) if local_bytes == old_bytes => {}
114 (Some(old_bytes), Some(new_bytes), None) if old_bytes == new_bytes => {}
115 (None, Some(new_bytes), None) => {
116 merged.insert(path.clone(), new_bytes.clone());
117 }
118 (None, None, Some(local_bytes)) => {
119 merged.insert(path.clone(), local_bytes.clone());
120 }
121 (Some(_), Some(new_bytes), None) => {
122 merged.insert(path.clone(), new_bytes.clone());
123 }
124 _ => {
125 conflicts.push(path.clone());
126 }
127 }
128 }
129 if conflicts.is_empty() {
130 Ok(merged)
131 } else {
132 Err(conflicts)
133 }
134}
135
136pub fn upstream_merge_conflict_message(
137 fork: &str,
138 upstream_name: &str,
139 old_version: &str,
140 new_version: &str,
141 paths: &[String],
142) -> String {
143 format!(
144 "upstream merge conflict in {fork} while refreshing {upstream_name} {old_version} to {new_version}: {}",
145 paths.join(", ")
146 )
147}
148
149pub fn next_upstream_constraint(current: &str, new_version: &str) -> String {
150 let trimmed = current.trim();
151 if trimmed == "*"
152 || trimmed.starts_with("~>")
153 || trimmed.starts_with('^')
154 || trimmed.starts_with(">=")
155 || trimmed.starts_with('>')
156 || trimmed.starts_with("<=")
157 || trimmed.starts_with('<')
158 {
159 return current.to_string();
160 }
161 format!("= {new_version}")
162}
163
164fn string_from_value(value: &LiteralValue) -> PrayResult<String> {
165 value
166 .as_string()
167 .map(str::to_string)
168 .ok_or_else(|| PrayError::Parse {
169 kind: "prayspec",
170 message: format!("expected string-like literal, found {:?}", value),
171 })
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn clean_replica_takes_new_tree() {
180 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
181 let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
182 let merged = merge_content_files(&old, &new, &old).expect("merge");
183 assert_eq!(
184 merged.get("exports/a.md").map(Vec::as_slice),
185 Some(&b"new"[..])
186 );
187 }
188
189 #[test]
190 fn local_edit_against_upstream_change_conflicts() {
191 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
192 let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
193 let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
194 let error = merge_content_files(&old, &new, &local).expect_err("conflict");
195 assert!(error.to_string().contains("exports/a.md"));
196 }
197
198 #[test]
199 fn merge_lists_every_conflicting_path() {
200 let old = BTreeMap::from([
201 ("README.md".to_string(), b"old readme".to_vec()),
202 ("exports/a.md".to_string(), b"old".to_vec()),
203 ]);
204 let new = BTreeMap::from([
205 ("README.md".to_string(), b"new readme".to_vec()),
206 ("exports/a.md".to_string(), b"new".to_vec()),
207 ]);
208 let local = BTreeMap::from([
209 ("README.md".to_string(), b"local readme".to_vec()),
210 ("exports/a.md".to_string(), b"edit".to_vec()),
211 ]);
212 let paths = try_merge_content_files(&old, &new, &local).expect_err("conflict");
213 assert!(paths.contains(&"README.md".to_string()));
214 assert!(paths.contains(&"exports/a.md".to_string()));
215 let message =
216 upstream_merge_conflict_message("fork/base", "sample/base", "1.4.3", "1.4.4", &paths);
217 assert!(message.contains("fork/base"));
218 assert!(message.contains("sample/base 1.4.3 to 1.4.4"));
219 assert!(message.contains("README.md"));
220 assert!(message.contains("exports/a.md"));
221 }
222
223 #[test]
224 fn local_edit_kept_when_upstream_unchanged() {
225 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
226 let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
227 let merged = merge_content_files(&old, &old, &local).expect("merge");
228 assert_eq!(
229 merged.get("exports/a.md").map(Vec::as_slice),
230 Some(&b"edit"[..])
231 );
232 }
233
234 #[test]
235 fn exact_constraint_rewrites_to_new_version() {
236 assert_eq!(next_upstream_constraint("= 1.4.3", "1.4.4"), "= 1.4.4");
237 assert_eq!(next_upstream_constraint("1.4.3", "1.4.4"), "= 1.4.4");
238 assert_eq!(next_upstream_constraint("~> 1.4", "1.4.4"), "~> 1.4");
239 }
240}