Skip to main content

pray_core/
package_upstream.rs

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(_), Some(new_bytes), None) => {
126                merged.insert(path.clone(), new_bytes.clone());
127            }
128            _ => {
129                conflicts.push(path.clone());
130            }
131        }
132    }
133    if conflicts.is_empty() {
134        Ok(merged)
135    } else {
136        Err(conflicts)
137    }
138}
139
140pub fn upstream_merge_conflict_message(
141    fork: &str,
142    upstream_name: &str,
143    old_version: &str,
144    new_version: &str,
145    paths: &[String],
146) -> String {
147    format!(
148        "upstream merge conflict in {fork} while refreshing {upstream_name} {old_version} to {new_version}: {}",
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 clean_replica_takes_new_tree() {
184        let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
185        let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
186        let merged = merge_content_files(&old, &new, &old).expect("merge");
187        assert_eq!(
188            merged.get("exports/a.md").map(Vec::as_slice),
189            Some(&b"new"[..])
190        );
191    }
192
193    #[test]
194    fn local_edit_against_upstream_change_conflicts() {
195        let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
196        let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
197        let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
198        let error = merge_content_files(&old, &new, &local).expect_err("conflict");
199        assert!(error.to_string().contains("exports/a.md"));
200    }
201
202    #[test]
203    fn merge_lists_every_conflicting_path() {
204        let old = BTreeMap::from([
205            ("README.md".to_string(), b"old readme".to_vec()),
206            ("exports/a.md".to_string(), b"old".to_vec()),
207        ]);
208        let new = BTreeMap::from([
209            ("README.md".to_string(), b"new readme".to_vec()),
210            ("exports/a.md".to_string(), b"new".to_vec()),
211        ]);
212        let local = BTreeMap::from([
213            ("README.md".to_string(), b"local readme".to_vec()),
214            ("exports/a.md".to_string(), b"edit".to_vec()),
215        ]);
216        let paths = try_merge_content_files(&old, &new, &local).expect_err("conflict");
217        assert!(paths.contains(&"README.md".to_string()));
218        assert!(paths.contains(&"exports/a.md".to_string()));
219        let message =
220            upstream_merge_conflict_message("fork/base", "sample/base", "1.4.3", "1.4.4", &paths);
221        assert!(message.contains("fork/base"));
222        assert!(message.contains("sample/base 1.4.3 to 1.4.4"));
223        assert!(message.contains("README.md"));
224        assert!(message.contains("exports/a.md"));
225    }
226
227    #[test]
228    fn local_edit_kept_when_upstream_unchanged() {
229        let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
230        let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
231        let merged = merge_content_files(&old, &old, &local).expect("merge");
232        assert_eq!(
233            merged.get("exports/a.md").map(Vec::as_slice),
234            Some(&b"edit"[..])
235        );
236    }
237
238    #[test]
239    fn exact_constraint_rewrites_to_new_version() {
240        assert_eq!(next_upstream_constraint("= 1.4.3", "1.4.4"), "= 1.4.4");
241        assert_eq!(next_upstream_constraint("1.4.3", "1.4.4"), "= 1.4.4");
242        assert_eq!(next_upstream_constraint("~> 1.4", "1.4.4"), "~> 1.4");
243    }
244}