1use serde_json::{Map, Value};
15use std::path::{Path, PathBuf};
16use vivacity_core::phpjson::{empty_stdclass, php_json_encode_with, FLAGS_JSONFILE};
17
18use crate::json_manipulator::{detect_indenting, JsonManipulator, ManipulatorError};
19
20#[derive(Debug, thiserror::Error)]
21pub enum ConfigSourceError {
22 #[error("{0}")]
23 Manipulator(#[from] ManipulatorError),
24 #[error("cannot read {path}: {source}")]
25 Read {
26 path: PathBuf,
27 source: std::io::Error,
28 },
29 #[error("cannot write {path}: {source}")]
30 Write {
31 path: PathBuf,
32 source: std::io::Error,
33 },
34 #[error("{0}")]
35 Encode(String),
36}
37
38type Result<T> = std::result::Result<T, ConfigSourceError>;
39
40pub struct JsonConfigSource {
42 path: PathBuf,
43}
44
45enum Edit<'a> {
48 RemoveSubNode(&'a str, &'a str),
49 RemoveMainKeyIfEmpty(&'a str),
50 RemoveConfigSetting(&'a str),
51 AddLink(&'a str, &'a str, &'a str, bool),
52}
53
54impl JsonConfigSource {
55 pub fn new(path: impl Into<PathBuf>) -> Self {
56 Self { path: path.into() }
57 }
58
59 pub fn remove_link(&self, link_type: &str, name: &str) -> Result<()> {
62 self.manipulate(Edit::RemoveSubNode(link_type, name))?;
63 self.manipulate(Edit::RemoveMainKeyIfEmpty(link_type))
64 }
65
66 pub fn add_link(
69 &self,
70 link_type: &str,
71 name: &str,
72 constraint: &str,
73 sort: bool,
74 ) -> Result<()> {
75 self.manipulate(Edit::AddLink(link_type, name, constraint, sort))
76 }
77
78 pub fn remove_config_setting(&self, name: &str) -> Result<()> {
81 self.manipulate(Edit::RemoveConfigSetting(name))
82 }
83
84 fn manipulate(&self, edit: Edit<'_>) -> Result<()> {
86 let contents = match std::fs::read_to_string(&self.path) {
88 Ok(s) => s,
89 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
90 "{\n \"config\": {\n }\n}\n".to_owned()
91 }
92 Err(source) => {
93 return Err(ConfigSourceError::Read {
94 path: self.path.clone(),
95 source,
96 })
97 }
98 };
99 let mut manipulator = JsonManipulator::new(&contents)?;
100 let done = match &edit {
101 Edit::RemoveSubNode(main, name) => manipulator.remove_sub_node(main, name)?,
102 Edit::RemoveMainKeyIfEmpty(key) => manipulator.remove_main_key_if_empty(key)?,
103 Edit::RemoveConfigSetting(name) => manipulator.remove_config_setting(name)?,
104 Edit::AddLink(t, n, c, sort) => manipulator.add_link(t, n, c, *sort)?,
105 };
106 if done {
107 return self.write_text(&manipulator.contents());
108 }
109 let mut config: Value = serde_json::from_str(&contents).map_err(|e| {
111 ConfigSourceError::Encode(format!("cannot decode {}: {e}", self.path.display()))
112 })?;
113 let indent = detect_indenting(&contents)?;
114 if let Value::Object(root) = &mut config {
115 match edit {
116 Edit::RemoveSubNode(main, name) => {
117 if let Some(Value::Object(section)) = root.get_mut(main) {
118 section.shift_remove(name);
119 }
120 }
121 Edit::RemoveMainKeyIfEmpty(key) => {
122 let empty = match root.get(key) {
126 Some(Value::Object(m)) => m.is_empty(),
127 Some(Value::Array(a)) => a.is_empty(),
128 _ => false,
129 };
130 if empty {
131 root.shift_remove(key);
132 }
133 }
134 Edit::RemoveConfigSetting(name) => {
135 if let Some(Value::Object(cfg)) = root.get_mut("config") {
139 cfg.shift_remove(name);
140 }
141 }
142 Edit::AddLink(t, n, c, _) => {
143 let replacement = match root.get(t) {
146 Some(Value::Object(_)) => None,
147 Some(Value::Array(a)) => Some(Value::Object(
149 a.iter()
150 .enumerate()
151 .map(|(i, v)| (i.to_string(), v.clone()))
152 .collect(),
153 )),
154 Some(Value::Null) | None => Some(Value::Object(Map::new())),
155 Some(other) => {
156 return Err(ConfigSourceError::Encode(format!(
157 "Cannot use a scalar value as an array ({t}: {other})"
158 )))
159 }
160 };
161 if let Some(r) = replacement {
162 root.insert(t.to_owned(), r);
163 }
164 if let Some(Value::Object(section)) = root.get_mut(t) {
165 section.insert(n.to_owned(), Value::String(c.to_owned()));
166 }
167 }
168 }
169 fixup_empty_objects(root);
170 }
171 let mut text = php_json_encode_with(&config, FLAGS_JSONFILE)
172 .map_err(|e| ConfigSourceError::Encode(e.to_string()))?;
173 if indent != " " {
174 text = reindent(&text, &indent);
175 }
176 text.push('\n');
177 if std::fs::read_to_string(&self.path).is_ok_and(|old| old == text) {
179 return Ok(());
180 }
181 self.write_text(&text)
182 }
183
184 fn write_text(&self, text: &str) -> Result<()> {
185 std::fs::write(&self.path, text).map_err(|source| ConfigSourceError::Write {
186 path: self.path.clone(),
187 source,
188 })
189 }
190}
191
192fn fixup_empty_objects(root: &mut Map<String, Value>) {
195 let is_empty_array = |v: &Value| match v {
196 Value::Object(m) => m.is_empty(),
197 Value::Array(a) => a.is_empty(),
198 _ => false,
199 };
200 if let Some(Value::Object(cfg)) = root.get_mut("config") {
201 if let Some(Value::Object(policy)) = cfg.get_mut("policy") {
202 for (_, v) in policy.iter_mut() {
203 if is_empty_array(v) {
204 *v = empty_stdclass();
205 }
206 }
207 if policy.is_empty() {
208 cfg.insert("policy".into(), empty_stdclass());
209 }
210 }
211 for prop in [
212 "platform",
213 "http-basic",
214 "bearer",
215 "gitlab-token",
216 "gitlab-oauth",
217 "github-oauth",
218 "custom-headers",
219 "forgejo-token",
220 "preferred-install",
221 ] {
222 if cfg.get(prop).is_some_and(is_empty_array) {
223 cfg.insert(prop.into(), empty_stdclass());
224 }
225 }
226 }
227 for section in ["autoload", "autoload-dev"] {
228 if let Some(Value::Object(a)) = root.get_mut(section) {
229 for prop in ["psr-0", "psr-4"] {
230 if a.get(prop).is_some_and(is_empty_array) {
231 a.insert(prop.into(), empty_stdclass());
232 }
233 }
234 }
235 }
236 for prop in [
237 "require",
238 "require-dev",
239 "conflict",
240 "provide",
241 "replace",
242 "suggest",
243 "config",
244 "autoload",
245 "autoload-dev",
246 "scripts",
247 "scripts-descriptions",
248 "scripts-aliases",
249 "support",
250 ] {
251 if root.get(prop).is_some_and(is_empty_array) {
252 root.insert(prop.into(), empty_stdclass());
253 }
254 }
255}
256
257pub fn reindent(text: &str, indent: &str) -> String {
260 let mut out = String::with_capacity(text.len());
261 for (i, line) in text.split('\n').enumerate() {
262 if i > 0 {
263 out.push('\n');
264 }
265 let spaces = line.len() - line.trim_start_matches(' ').len();
266 if spaces >= 4 {
267 out.push_str(&indent.repeat(spaces / 4));
270 out.push_str(&line[spaces..]);
271 } else {
272 out.push_str(line);
273 }
274 }
275 out
276}
277
278pub fn composer_file(project: &Path) -> PathBuf {
280 match std::env::var("COMPOSER") {
281 Ok(f) if !f.trim().is_empty() => project.join(f.trim()),
282 _ => project.join("composer.json"),
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn reindent_replaces_leading_groups_of_four() {
292 assert_eq!(
293 reindent("{\n \"a\": {\n \"b\": 1\n }\n}", "\t"),
294 "{\n\t\"a\": {\n\t\t\"b\": 1\n\t}\n}"
295 );
296 assert_eq!(reindent("{\n \"a\": 1\n}", " "), "{\n \"a\": 1\n}");
297 }
298
299 #[test]
300 fn remove_link_edits_in_place_and_drops_empty_section() {
301 let dir = tempfile::tempdir().expect("tmp");
302 let path = dir.path().join("composer.json");
303 std::fs::write(
304 &path,
305 "{\n \"name\": \"a/b\",\n \"require\": {\n \"c/d\": \"^1\"\n }\n}\n",
306 )
307 .expect("write");
308 let src = JsonConfigSource::new(&path);
309 src.remove_link("require", "c/d").expect("remove");
310 assert_eq!(
311 std::fs::read_to_string(&path).expect("read"),
312 "{\n \"name\": \"a/b\"\n}\n"
313 );
314 }
315}