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 if is_clean_replica(old_content, local_content) {
73 return Ok(new_content.clone());
74 }
75 let mut paths = BTreeMap::new();
76 for path in old_content
77 .keys()
78 .chain(new_content.keys())
79 .chain(local_content.keys())
80 {
81 paths.insert(path.clone(), ());
82 }
83 let mut merged = BTreeMap::new();
84 for path in paths.keys() {
85 let old = old_content.get(path);
86 let new = new_content.get(path);
87 let local = local_content.get(path);
88 match (old, new, local) {
89 (_, Some(new_bytes), Some(local_bytes)) if local_bytes == new_bytes => {
90 merged.insert(path.clone(), new_bytes.clone());
91 }
92 (Some(old_bytes), Some(new_bytes), Some(local_bytes)) if local_bytes == old_bytes => {
93 merged.insert(path.clone(), new_bytes.clone());
94 }
95 (Some(old_bytes), Some(new_bytes), Some(local_bytes)) if old_bytes == new_bytes => {
96 merged.insert(path.clone(), local_bytes.clone());
97 }
98 (Some(old_bytes), None, Some(local_bytes)) if local_bytes == old_bytes => {}
99 (Some(old_bytes), Some(new_bytes), None) if old_bytes == new_bytes => {}
100 (None, Some(new_bytes), None) => {
101 merged.insert(path.clone(), new_bytes.clone());
102 }
103 (None, None, Some(local_bytes)) => {
104 merged.insert(path.clone(), local_bytes.clone());
105 }
106 (Some(_), Some(new_bytes), None) => {
107 merged.insert(path.clone(), new_bytes.clone());
108 }
109 _ => {
110 return Err(PrayError::Resolution(format!(
111 "upstream merge conflict in {path}"
112 )));
113 }
114 }
115 }
116 Ok(merged)
117}
118
119pub fn next_upstream_constraint(current: &str, new_version: &str) -> String {
120 let trimmed = current.trim();
121 if trimmed == "*"
122 || trimmed.starts_with("~>")
123 || trimmed.starts_with('^')
124 || trimmed.starts_with(">=")
125 || trimmed.starts_with('>')
126 || trimmed.starts_with("<=")
127 || trimmed.starts_with('<')
128 {
129 return current.to_string();
130 }
131 format!("= {new_version}")
132}
133
134fn string_from_value(value: &LiteralValue) -> PrayResult<String> {
135 value
136 .as_string()
137 .map(str::to_string)
138 .ok_or_else(|| PrayError::Parse {
139 kind: "prayspec",
140 message: format!("expected string-like literal, found {:?}", value),
141 })
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn clean_replica_takes_new_tree() {
150 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
151 let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
152 let merged = merge_content_files(&old, &new, &old).expect("merge");
153 assert_eq!(
154 merged.get("exports/a.md").map(Vec::as_slice),
155 Some(&b"new"[..])
156 );
157 }
158
159 #[test]
160 fn local_edit_against_upstream_change_conflicts() {
161 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
162 let new = BTreeMap::from([("exports/a.md".to_string(), b"new".to_vec())]);
163 let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
164 let error = merge_content_files(&old, &new, &local).expect_err("conflict");
165 assert!(error.to_string().contains("exports/a.md"));
166 }
167
168 #[test]
169 fn local_edit_kept_when_upstream_unchanged() {
170 let old = BTreeMap::from([("exports/a.md".to_string(), b"old".to_vec())]);
171 let local = BTreeMap::from([("exports/a.md".to_string(), b"edit".to_vec())]);
172 let merged = merge_content_files(&old, &old, &local).expect("merge");
173 assert_eq!(
174 merged.get("exports/a.md").map(Vec::as_slice),
175 Some(&b"edit"[..])
176 );
177 }
178
179 #[test]
180 fn exact_constraint_rewrites_to_new_version() {
181 assert_eq!(next_upstream_constraint("= 1.4.3", "1.4.4"), "= 1.4.4");
182 assert_eq!(next_upstream_constraint("1.4.3", "1.4.4"), "= 1.4.4");
183 assert_eq!(next_upstream_constraint("~> 1.4", "1.4.4"), "~> 1.4");
184 }
185}