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
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}