1use crate::config::{Config, ConfigError};
8use crate::partial_config::{Merge, PartialConfig, SessionConfig};
9use serde_json::Value;
10use std::path::{Path, PathBuf};
11
12fn strip_nulls(value: Value) -> Option<Value> {
20 match value {
21 Value::Null => None,
22 Value::Object(map) => {
23 let filtered: serde_json::Map<String, Value> = map
24 .into_iter()
25 .filter_map(|(k, v)| strip_nulls(v).map(|v| (k, v)))
26 .collect();
27 if filtered.is_empty() {
28 None
29 } else {
30 Some(Value::Object(filtered))
31 }
32 }
33 Value::Array(arr) => {
34 let filtered: Vec<Value> = arr.into_iter().filter_map(strip_nulls).collect();
35 Some(Value::Array(filtered))
36 }
37 other => Some(other),
38 }
39}
40
41fn strip_empty_defaults(value: Value) -> Option<Value> {
44 match value {
45 Value::Null => None,
46 Value::String(s) if s.is_empty() => None,
47 Value::Array(arr) if arr.is_empty() => None,
48 Value::Object(map) => {
49 let filtered: serde_json::Map<String, Value> = map
50 .into_iter()
51 .filter_map(|(k, v)| strip_empty_defaults(v).map(|v| (k, v)))
52 .collect();
53 if filtered.is_empty() {
54 None
55 } else {
56 Some(Value::Object(filtered))
57 }
58 }
59 Value::Array(arr) => {
60 let filtered: Vec<Value> = arr.into_iter().filter_map(strip_empty_defaults).collect();
61 if filtered.is_empty() {
62 None
63 } else {
64 Some(Value::Array(filtered))
65 }
66 }
67 other => Some(other),
68 }
69}
70
71fn set_json_pointer(root: &mut Value, pointer: &str, value: Value) {
74 if pointer.is_empty() || pointer == "/" {
75 *root = value;
76 return;
77 }
78
79 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
80
81 let mut current = root;
82 for (i, part) in parts.iter().enumerate() {
83 if i == parts.len() - 1 {
84 if let Value::Object(map) = current {
86 map.insert(part.to_string(), value);
87 }
88 return;
89 }
90
91 if let Value::Object(map) = current {
93 if !map.contains_key(*part) {
94 map.insert(part.to_string(), Value::Object(Default::default()));
95 }
96 current = map.get_mut(*part).unwrap();
97 } else {
98 return; }
100 }
101}
102
103pub(crate) fn remove_json_pointer(root: &mut Value, pointer: &str) {
105 if pointer.is_empty() || pointer == "/" {
106 return;
107 }
108
109 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
110
111 let mut current = root;
112 for (i, part) in parts.iter().enumerate() {
113 if i == parts.len() - 1 {
114 if let Value::Object(map) = current {
116 map.remove(*part);
117 }
118 return;
119 }
120
121 if let Value::Object(map) = current {
123 if let Some(next) = map.get_mut(*part) {
124 current = next;
125 } else {
126 return; }
128 } else {
129 return; }
131 }
132}
133
134fn find_changed_paths(old: &Value, new: &Value) -> std::collections::HashSet<String> {
137 let mut changed = std::collections::HashSet::new();
138 find_changed_paths_recursive(old, new, String::new(), &mut changed);
139 changed
140}
141
142fn find_changed_paths_recursive(
143 old: &Value,
144 new: &Value,
145 prefix: String,
146 changed: &mut std::collections::HashSet<String>,
147) {
148 match (old, new) {
149 (Value::Object(old_map), Value::Object(new_map)) => {
150 let all_keys: std::collections::HashSet<_> =
152 old_map.keys().chain(new_map.keys()).collect();
153 for key in all_keys {
154 let path = if prefix.is_empty() {
155 format!("/{}", key)
156 } else {
157 format!("{}/{}", prefix, key)
158 };
159 let old_val = old_map.get(key).unwrap_or(&Value::Null);
160 let new_val = new_map.get(key).unwrap_or(&Value::Null);
161 find_changed_paths_recursive(old_val, new_val, path, changed);
162 }
163 }
164 (old_val, new_val) if old_val != new_val => {
165 if !prefix.is_empty() {
167 changed.insert(prefix);
168 }
169 }
170 _ => {} }
172}
173
174fn write_clean_value_to_path(path: &Path, value: Value) -> Result<(), ConfigError> {
183 if let Some(parent_dir) = path.parent() {
184 std::fs::create_dir_all(parent_dir)
185 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
186 }
187 let stripped = strip_nulls(value).unwrap_or(Value::Object(Default::default()));
188 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
189
190 let output = render_config_text(path, &clean)?;
191 std::fs::write(path, output)
192 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
193 Ok(())
194}
195
196fn render_config_text(path: &Path, clean: &Value) -> Result<String, ConfigError> {
199 if let (Value::Object(_), Ok(existing)) = (clean, std::fs::read_to_string(path)) {
200 if let Some(text) = reconcile_preserving_comments(&existing, clean) {
201 return Ok(text);
202 }
203 }
204 serde_json::to_string_pretty(clean).map_err(|e| ConfigError::SerializeError(e.to_string()))
205}
206
207fn reconcile_preserving_comments(existing: &str, clean: &Value) -> Option<String> {
212 use jsonc_parser::cst::CstRootNode;
213
214 let Value::Object(target) = clean else {
215 return None;
216 };
217 let root = CstRootNode::parse(existing, &Default::default()).ok()?;
218 root.value()?.as_object()?;
221 let obj = root.object_value_or_set();
222 reconcile_cst_object(&obj, target);
223 Some(root.to_string())
224}
225
226fn reconcile_cst_object(
231 obj: &jsonc_parser::cst::CstObject,
232 target: &serde_json::Map<String, Value>,
233) {
234 use jsonc_parser::cst::CstObjectProp;
235
236 let prop_name = |prop: &CstObjectProp| -> Option<String> {
237 prop.name().and_then(|n| n.decoded_value().ok())
238 };
239
240 for prop in obj.properties() {
242 match prop_name(&prop) {
243 Some(name) if target.contains_key(&name) => {}
244 _ => prop.remove(),
245 }
246 }
247
248 for (key, new_value) in target {
250 match obj.get(key) {
251 Some(prop) => {
252 let current = prop.value().and_then(|n| n.to_serde_value());
255 if current.as_ref() == Some(new_value) {
256 continue;
257 }
258 match (new_value, prop.value().and_then(|n| n.as_object())) {
259 (Value::Object(child_target), Some(child_obj)) => {
261 reconcile_cst_object(&child_obj, child_target);
262 }
263 _ => prop.set_value(json_value_to_cst_input(new_value)),
265 }
266 }
267 None => {
268 obj.append(key, json_value_to_cst_input(new_value));
269 }
270 }
271 }
272}
273
274fn json_value_to_cst_input(value: &Value) -> jsonc_parser::cst::CstInputValue {
277 use jsonc_parser::cst::CstInputValue;
278 match value {
279 Value::Null => CstInputValue::Null,
280 Value::Bool(b) => CstInputValue::Bool(*b),
281 Value::Number(n) => CstInputValue::Number(n.to_string()),
282 Value::String(s) => CstInputValue::String(s.clone()),
283 Value::Array(arr) => {
284 CstInputValue::Array(arr.iter().map(json_value_to_cst_input).collect())
285 }
286 Value::Object(map) => CstInputValue::Object(
287 map.iter()
288 .map(|(k, v)| (k.clone(), json_value_to_cst_input(v)))
289 .collect(),
290 ),
291 }
292}
293
294fn read_existing_json(path: &Path) -> Result<Value, ConfigError> {
304 if !path.exists() {
305 return Ok(Value::Object(Default::default()));
306 }
307 let content = std::fs::read_to_string(path)
308 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
309 if content.trim().is_empty() {
310 return Ok(Value::Object(Default::default()));
311 }
312 crate::config::parse_config_jsonc(&content)
313 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))
314}
315
316pub const CURRENT_CONFIG_VERSION: u32 = 2;
323
324pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
326 let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
327
328 if version < 1 {
330 value = migrate_v0_to_v1(value)?;
331 }
332 if version < 2 {
333 value = migrate_v1_to_v2(value)?;
334 }
335
336 Ok(value)
337}
338
339fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
342 if let Value::Object(ref mut map) = value {
343 map.insert("version".to_string(), Value::Number(1.into()));
345
346 if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
348 if let Some(val) = editor_map.remove("tabSize") {
350 editor_map.entry("tab_size").or_insert(val);
351 }
352 if let Some(val) = editor_map.remove("lineNumbers") {
354 editor_map.entry("line_numbers").or_insert(val);
355 }
356 }
357 }
358 Ok(value)
359}
360
361fn migrate_v1_to_v2(mut value: Value) -> Result<Value, ConfigError> {
370 if let Value::Object(ref mut map) = value {
371 map.insert("version".to_string(), Value::Number(2.into()));
372
373 let left = map
374 .get_mut("editor")
375 .and_then(|editor| editor.as_object_mut())
376 .and_then(|editor| editor.get_mut("status_bar"))
377 .and_then(|status_bar| status_bar.as_object_mut())
378 .and_then(|status_bar| status_bar.get_mut("left"))
379 .and_then(|left| left.as_array_mut());
380
381 if let Some(left) = left {
382 let already_present = left.iter().any(|v| v.as_str() == Some("{remote}"));
383 if !already_present {
384 left.insert(0, Value::String("{remote}".to_string()));
385 }
386 }
387 }
388 Ok(value)
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum ConfigLayer {
394 System,
396 User,
398 Project,
400 Session,
402}
403
404impl ConfigLayer {
405 pub fn precedence(self) -> u8 {
407 match self {
408 Self::System => 0,
409 Self::User => 1,
410 Self::Project => 2,
411 Self::Session => 3,
412 }
413 }
414}
415
416pub struct ConfigResolver {
421 dir_context: DirectoryContext,
422 working_dir: PathBuf,
423}
424
425impl ConfigResolver {
426 pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
428 Self {
429 dir_context,
430 working_dir,
431 }
432 }
433
434 pub fn resolve(&self) -> Result<Config, ConfigError> {
441 let mut merged = self.load_session_layer()?.unwrap_or_default();
443
444 if let Some(project_partial) = self.load_project_layer()? {
446 tracing::debug!("Loaded project config layer");
447 merged.merge_from(&project_partial);
448 }
449
450 if let Some(platform_partial) = self.load_user_platform_layer()? {
452 tracing::debug!("Loaded user platform config layer");
453 merged.merge_from(&platform_partial);
454 }
455
456 if let Some(user_partial) = self.load_user_layer()? {
458 tracing::debug!("Loaded user config layer");
459 merged.merge_from(&user_partial);
460 }
461
462 Ok(merged.resolve())
464 }
465
466 pub fn user_config_path(&self) -> PathBuf {
468 self.dir_context.config_path()
469 }
470
471 pub fn project_config_path(&self) -> PathBuf {
474 let new_path = self.working_dir.join(".fresh").join("config.json");
475 if new_path.exists() {
476 return new_path;
477 }
478 let legacy_path = self.working_dir.join("config.json");
480 if legacy_path.exists() {
481 return legacy_path;
482 }
483 new_path
485 }
486
487 pub fn project_config_write_path(&self) -> PathBuf {
489 self.working_dir.join(".fresh").join("config.json")
490 }
491
492 pub fn session_config_path(&self) -> PathBuf {
494 self.working_dir.join(".fresh").join("session.json")
495 }
496
497 fn platform_config_filename() -> Option<&'static str> {
499 if cfg!(target_os = "linux") {
500 Some("config_linux.json")
501 } else if cfg!(target_os = "macos") {
502 Some("config_macos.json")
503 } else if cfg!(target_os = "windows") {
504 Some("config_windows.json")
505 } else {
506 None
507 }
508 }
509
510 pub fn user_platform_config_path(&self) -> Option<PathBuf> {
512 Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
513 }
514
515 pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
517 self.load_layer_from_path(&self.user_config_path())
518 }
519
520 pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
522 if let Some(path) = self.user_platform_config_path() {
523 self.load_layer_from_path(&path)
524 } else {
525 Ok(None)
526 }
527 }
528
529 pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
531 self.load_layer_from_path(&self.project_config_path())
532 }
533
534 pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
536 self.load_layer_from_path(&self.session_config_path())
537 }
538
539 fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
541 if !path.exists() {
542 return Ok(None);
543 }
544
545 let content = std::fs::read_to_string(path)
546 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
547
548 let value: Value = crate::config::parse_config_jsonc(&content)
550 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
551
552 let migrated = migrate_config(value)?;
554
555 let partial: PartialConfig = serde_json::from_value(migrated)
557 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
558
559 Ok(Some(partial))
560 }
561
562 fn layer_write_path(&self, layer: ConfigLayer) -> Result<PathBuf, ConfigError> {
565 match layer {
566 ConfigLayer::User => Ok(self.user_config_path()),
567 ConfigLayer::Project => Ok(self.project_config_write_path()),
568 ConfigLayer::Session => Ok(self.session_config_path()),
569 ConfigLayer::System => Err(ConfigError::ValidationError(
570 "Cannot write to System layer".to_string(),
571 )),
572 }
573 }
574
575 pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
577 let path = self.layer_write_path(layer)?;
578
579 let parent_partial = self.resolve_up_to_layer(layer)?;
580 let parent = PartialConfig::from(&parent_partial.resolve());
581 let current = PartialConfig::from(config);
582 let delta = diff_partial_config(¤t, &parent);
583
584 let existing: PartialConfig = if path.exists() {
588 let content = std::fs::read_to_string(&path)
589 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
590 if content.trim().is_empty() {
591 PartialConfig::default()
592 } else {
593 let value = crate::config::parse_config_jsonc(&content)
594 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
595 serde_json::from_value(value)
596 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?
597 }
598 } else {
599 PartialConfig::default()
600 };
601 let mut merged = delta;
602 merged.merge_from(&existing);
603
604 let merged_value = serde_json::to_value(&merged)
605 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
606 write_clean_value_to_path(&path, merged_value)
607 }
608
609 pub fn save_to_layer_with_baseline(
619 &self,
620 current: &Config,
621 baseline: &Config,
622 layer: ConfigLayer,
623 ) -> Result<(), ConfigError> {
624 let path = self.layer_write_path(layer)?;
625
626 let parent_partial = self.resolve_up_to_layer(layer)?;
627 let parent = PartialConfig::from(&parent_partial.resolve());
628
629 let current_json = serde_json::to_value(current)
630 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
631 let baseline_json = serde_json::to_value(baseline)
632 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
633 let parent_json = serde_json::to_value(&parent)
634 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
635
636 let changed_paths = find_changed_paths(&baseline_json, ¤t_json);
637
638 let mut result = read_existing_json(&path)?;
639
640 for pointer in &changed_paths {
642 let current_val = current_json.pointer(pointer);
643 let parent_val = parent_json.pointer(pointer);
644 if current_val == parent_val {
645 remove_json_pointer(&mut result, pointer);
646 } else if let Some(val) = current_val {
647 set_json_pointer(&mut result, pointer, val.clone());
648 }
649 }
650
651 write_clean_value_to_path(&path, result)
652 }
653
654 pub fn save_changes_to_layer(
659 &self,
660 changes: &std::collections::HashMap<String, serde_json::Value>,
661 deletions: &std::collections::HashSet<String>,
662 layer: ConfigLayer,
663 ) -> Result<(), ConfigError> {
664 let path = self.layer_write_path(layer)?;
665
666 let mut config_value = read_existing_json(&path)?;
667
668 for pointer in deletions {
669 remove_json_pointer(&mut config_value, pointer);
670 }
671 for (pointer, value) in changes {
672 set_json_pointer(&mut config_value, pointer, value.clone());
673 }
674
675 let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
677 ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
678 })?;
679
680 write_clean_value_to_path(&path, config_value)
681 }
682
683 pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
685 let path = self.session_config_path();
686
687 if let Some(parent_dir) = path.parent() {
689 std::fs::create_dir_all(parent_dir)
690 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
691 }
692
693 let json = serde_json::to_string_pretty(session)
694 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
695 std::fs::write(&path, json)
696 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
697
698 tracing::debug!("Saved session config to {}", path.display());
699 Ok(())
700 }
701
702 pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
704 match self.load_session_layer()? {
705 Some(partial) => Ok(SessionConfig::from(partial)),
706 None => Ok(SessionConfig::new()),
707 }
708 }
709
710 pub fn clear_session(&self) -> Result<(), ConfigError> {
712 let path = self.session_config_path();
713 if path.exists() {
714 std::fs::remove_file(&path)
715 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
716 tracing::debug!("Cleared session config at {}", path.display());
717 }
718 Ok(())
719 }
720
721 fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
724 let mut merged = PartialConfig::default();
725
726 if layer == ConfigLayer::Session {
732 if let Some(project) = self.load_project_layer()? {
734 merged = project;
735 }
736 if let Some(platform) = self.load_user_platform_layer()? {
737 merged.merge_from(&platform);
738 }
739 if let Some(user) = self.load_user_layer()? {
740 merged.merge_from(&user);
741 }
742 } else if layer == ConfigLayer::Project {
743 if let Some(platform) = self.load_user_platform_layer()? {
745 merged = platform;
746 }
747 if let Some(user) = self.load_user_layer()? {
748 merged.merge_from(&user);
749 }
750 }
751 Ok(merged)
754 }
755
756 pub fn get_layer_sources(
759 &self,
760 ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
761 use std::collections::HashMap;
762
763 let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
764
765 if let Some(session) = self.load_session_layer()? {
770 let json = serde_json::to_value(&session).unwrap_or_default();
771 collect_paths(&json, "", &mut |path| {
772 sources.insert(path, ConfigLayer::Session);
773 });
774 }
775
776 if let Some(project) = self.load_project_layer()? {
777 let json = serde_json::to_value(&project).unwrap_or_default();
778 collect_paths(&json, "", &mut |path| {
779 sources.entry(path).or_insert(ConfigLayer::Project);
780 });
781 }
782
783 if let Some(user) = self.load_user_layer()? {
784 let json = serde_json::to_value(&user).unwrap_or_default();
785 collect_paths(&json, "", &mut |path| {
786 sources.entry(path).or_insert(ConfigLayer::User);
787 });
788 }
789
790 Ok(sources)
793 }
794}
795
796fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
798where
799 F: FnMut(String),
800{
801 match value {
802 Value::Object(map) => {
803 for (key, val) in map {
804 let path = if prefix.is_empty() {
805 format!("/{}", key)
806 } else {
807 format!("{}/{}", prefix, key)
808 };
809 collect_paths(val, &path, collector);
810 }
811 }
812 Value::Null => {} _ => {
814 collector(prefix.to_string());
816 }
817 }
818}
819
820fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
823 let current_json = serde_json::to_value(current).unwrap_or_default();
825 let parent_json = serde_json::to_value(parent).unwrap_or_default();
826
827 let diff = json_diff(&parent_json, ¤t_json);
828
829 serde_json::from_value(diff).unwrap_or_default()
831}
832
833impl Config {
834 fn system_config_paths() -> Vec<PathBuf> {
839 let mut paths = Vec::with_capacity(2);
840
841 #[cfg(target_os = "macos")]
843 if let Some(home) = dirs::home_dir() {
844 let path = home.join(".config").join("fresh").join(Config::FILENAME);
845 if path.exists() {
846 paths.push(path);
847 }
848 }
849
850 if let Some(config_dir) = dirs::config_dir() {
852 let path = config_dir.join("fresh").join(Config::FILENAME);
853 if !paths.contains(&path) && path.exists() {
854 paths.push(path);
855 }
856 }
857
858 paths
859 }
860
861 fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
869 let local = Self::local_config_path(working_dir);
870 let mut paths = Vec::with_capacity(3);
871
872 if local.exists() {
873 paths.push(local);
874 }
875
876 paths.extend(Self::system_config_paths());
877 paths
878 }
879
880 pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
884 Self::config_search_paths(working_dir).into_iter().next()
885 }
886
887 pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
892 let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
893 match resolver.resolve() {
894 Ok(config) => {
895 tracing::info!("Loaded layered config for {}", working_dir.display());
896 config
897 }
898 Err(e) => {
899 tracing::warn!("Failed to load layered config: {}, using defaults", e);
900 Self::default()
901 }
902 }
903 }
904
905 pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
913 for path in Self::config_search_paths(working_dir) {
914 if let Ok(contents) = std::fs::read_to_string(&path) {
915 match crate::config::parse_config_jsonc(&contents) {
916 Ok(value) => return value,
917 Err(e) => {
918 tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
919 }
920 }
921 }
922 }
923 serde_json::Value::Object(serde_json::Map::new())
924 }
925}
926
927fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
930 use serde_json::Value;
931
932 match (defaults, current) {
933 (Value::Object(def_map), Value::Object(cur_map)) => {
935 let mut result = serde_json::Map::new();
936
937 for (key, cur_val) in cur_map {
938 if let Some(def_val) = def_map.get(key) {
939 let diff = json_diff(def_val, cur_val);
941 if !is_empty_diff(&diff) {
943 result.insert(key.clone(), diff);
944 }
945 } else {
946 if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
948 result.insert(key.clone(), stripped);
949 }
950 }
951 }
952
953 Value::Object(result)
954 }
955 _ => {
957 if let Value::String(s) = current {
959 if s.is_empty() {
960 return Value::Object(serde_json::Map::new()); }
962 }
963 if defaults == current {
964 Value::Object(serde_json::Map::new()) } else {
966 current.clone()
967 }
968 }
969 }
970}
971
972fn is_empty_diff(value: &serde_json::Value) -> bool {
974 match value {
975 serde_json::Value::Object(map) => map.is_empty(),
976 _ => false,
977 }
978}
979
980#[derive(Debug, Clone)]
991pub struct DirectoryContext {
992 pub data_dir: std::path::PathBuf,
995
996 pub config_dir: std::path::PathBuf,
999
1000 pub home_dir: Option<std::path::PathBuf>,
1002
1003 pub documents_dir: Option<std::path::PathBuf>,
1005
1006 pub downloads_dir: Option<std::path::PathBuf>,
1008}
1009
1010impl DirectoryContext {
1011 pub fn from_system() -> std::io::Result<Self> {
1014 let data_dir = dirs::data_dir()
1015 .ok_or_else(|| {
1016 std::io::Error::new(
1017 std::io::ErrorKind::NotFound,
1018 "Could not determine data directory",
1019 )
1020 })?
1021 .join("fresh");
1022
1023 let config_dir = Self::default_config_dir().ok_or_else(|| {
1024 std::io::Error::new(
1025 std::io::ErrorKind::NotFound,
1026 "Could not determine config directory",
1027 )
1028 })?;
1029
1030 Ok(Self {
1031 data_dir,
1032 config_dir,
1033 home_dir: dirs::home_dir(),
1034 documents_dir: dirs::document_dir(),
1035 downloads_dir: dirs::download_dir(),
1036 })
1037 }
1038
1039 pub fn for_testing(temp_dir: &std::path::Path) -> Self {
1042 Self {
1043 data_dir: temp_dir.join("data"),
1044 config_dir: temp_dir.join("config"),
1045 home_dir: Some(temp_dir.join("home")),
1046 documents_dir: Some(temp_dir.join("documents")),
1047 downloads_dir: Some(temp_dir.join("downloads")),
1048 }
1049 }
1050
1051 pub fn recovery_dir(&self) -> std::path::PathBuf {
1053 self.data_dir.join("recovery")
1054 }
1055
1056 pub fn workspaces_dir(&self) -> std::path::PathBuf {
1058 self.data_dir.join("workspaces")
1059 }
1060
1061 pub fn project_state_dir(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
1077 let canonical = working_dir
1078 .canonicalize()
1079 .unwrap_or_else(|_| working_dir.to_path_buf());
1080 self.workspaces_dir()
1081 .join(crate::workspace::encode_path_for_filename(&canonical))
1082 }
1083
1084 pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
1088 let safe_name = history_name.replace(':', "_");
1090 self.data_dir.join(format!("{}_history.json", safe_name))
1091 }
1092
1093 pub fn search_history_path(&self) -> std::path::PathBuf {
1095 self.prompt_history_path("search")
1096 }
1097
1098 pub fn replace_history_path(&self) -> std::path::PathBuf {
1100 self.prompt_history_path("replace")
1101 }
1102
1103 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
1105 self.prompt_history_path("goto_line")
1106 }
1107
1108 pub fn terminals_dir(&self) -> std::path::PathBuf {
1110 self.data_dir.join("terminals")
1111 }
1112
1113 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
1115 let encoded = crate::workspace::encode_path_for_filename(working_dir);
1116 self.terminals_dir().join(encoded)
1117 }
1118
1119 pub fn working_data_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
1125 let encoded = crate::workspace::encode_path_for_filename(working_dir);
1126 self.data_dir.join("workdirs").join(encoded)
1127 }
1128
1129 pub fn config_path(&self) -> std::path::PathBuf {
1131 self.config_dir.join(Config::FILENAME)
1132 }
1133
1134 pub fn themes_dir(&self) -> std::path::PathBuf {
1136 self.config_dir.join("themes")
1137 }
1138
1139 pub fn grammars_dir(&self) -> std::path::PathBuf {
1141 self.config_dir.join("grammars")
1142 }
1143
1144 pub fn plugins_dir(&self) -> std::path::PathBuf {
1146 self.config_dir.join("plugins")
1147 }
1148
1149 fn default_config_dir() -> Option<std::path::PathBuf> {
1156 #[cfg(target_os = "macos")]
1157 {
1158 dirs::home_dir().map(|p| p.join(".config").join("fresh"))
1159 }
1160
1161 #[cfg(not(target_os = "macos"))]
1162 {
1163 dirs::config_dir().map(|p| p.join("fresh"))
1164 }
1165 }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use tempfile::TempDir;
1172
1173 fn create_test_resolver() -> (TempDir, ConfigResolver) {
1174 let temp_dir = TempDir::new().unwrap();
1175 let dir_context = DirectoryContext::for_testing(temp_dir.path());
1176 let working_dir = temp_dir.path().join("project");
1177 std::fs::create_dir_all(&working_dir).unwrap();
1178 let resolver = ConfigResolver::new(dir_context, working_dir);
1179 (temp_dir, resolver)
1180 }
1181
1182 #[test]
1183 fn resolver_returns_defaults_when_no_config_files() {
1184 let (_temp, resolver) = create_test_resolver();
1185 let config = resolver.resolve().unwrap();
1186
1187 assert_eq!(config.editor.tab_size, 4);
1189 assert!(config.editor.line_numbers);
1190 }
1191
1192 #[test]
1193 fn resolver_loads_user_layer() {
1194 let (temp, resolver) = create_test_resolver();
1195
1196 let user_config_path = resolver.user_config_path();
1198 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1199 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1200
1201 let config = resolver.resolve().unwrap();
1202 assert_eq!(config.editor.tab_size, 2);
1203 assert!(config.editor.line_numbers); drop(temp);
1205 }
1206
1207 #[test]
1208 fn resolver_loads_user_layer_with_comments() {
1209 let (temp, resolver) = create_test_resolver();
1212
1213 let user_config_path = resolver.user_config_path();
1214 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1215 std::fs::write(
1216 &user_config_path,
1217 r#"{
1218 // I like a 7-space tab in this project
1219 "editor": {
1220 "tab_size": 7, /* trailing comma below is allowed too */
1221 "line_numbers": false,
1222 }
1223 }"#,
1224 )
1225 .unwrap();
1226
1227 let config = resolver.resolve().unwrap();
1228 assert_eq!(
1229 config.editor.tab_size, 7,
1230 "commented user config should still apply tab_size"
1231 );
1232 assert!(
1233 !config.editor.line_numbers,
1234 "commented user config should still apply line_numbers"
1235 );
1236 drop(temp);
1237 }
1238
1239 #[test]
1240 fn resolver_loads_project_layer_with_comments() {
1241 let (temp, resolver) = create_test_resolver();
1243
1244 let project_config_path = resolver.project_config_write_path();
1245 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1246 std::fs::write(
1247 &project_config_path,
1248 "{\n // project override\n \"editor\": { \"tab_size\": 3 }\n}\n",
1249 )
1250 .unwrap();
1251
1252 let config = resolver.resolve().unwrap();
1253 assert_eq!(config.editor.tab_size, 3);
1254 drop(temp);
1255 }
1256
1257 #[test]
1258 fn save_preserves_external_commented_values() {
1259 let (temp, resolver) = create_test_resolver();
1263
1264 let user_config_path = resolver.user_config_path();
1265 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1266 std::fs::write(
1267 &user_config_path,
1268 r#"{
1269 // hand-edited by the user
1270 "editor": {
1271 "tab_size": 7
1272 }
1273 }"#,
1274 )
1275 .unwrap();
1276
1277 let mut config = resolver.resolve().unwrap();
1279 assert_eq!(config.editor.tab_size, 7);
1280 config.editor.line_numbers = false;
1281 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1282
1283 let reloaded = resolver.resolve().unwrap();
1285 assert_eq!(
1286 reloaded.editor.tab_size, 7,
1287 "saving an unrelated field must not drop the commented tab_size"
1288 );
1289 assert!(!reloaded.editor.line_numbers);
1290 drop(temp);
1291 }
1292
1293 #[test]
1294 fn load_from_file_accepts_comments() {
1295 let temp = TempDir::new().unwrap();
1298 let path = temp.path().join("config-with-comments.json");
1299 std::fs::write(
1300 &path,
1301 r#"{
1302 // 2-space tabs, no gutter
1303 "editor": {
1304 "tab_size": 2,
1305 "line_numbers": false /* turn off the gutter */
1306 }
1307 }"#,
1308 )
1309 .unwrap();
1310
1311 let config = Config::load_from_file(&path).expect("commented --config file should load");
1312 assert_eq!(config.editor.tab_size, 2);
1313 assert!(!config.editor.line_numbers);
1314 }
1315
1316 #[test]
1317 fn reconcile_preserves_comments_and_unchanged_inline_annotations() {
1318 let existing = "{\n \
1322 // top-of-file note\n \
1323 \"editor\": {\n \
1324 \"tab_size\": 7, // my preferred width\n \
1325 \"line_numbers\": true\n \
1326 }\n\
1327 }\n";
1328
1329 let target = serde_json::json!({
1330 "editor": { "tab_size": 7, "line_numbers": false }
1331 });
1332
1333 let out =
1334 reconcile_preserving_comments(existing, &target).expect("object root should reconcile");
1335
1336 assert!(
1337 out.contains("// top-of-file note"),
1338 "file comment lost:\n{out}"
1339 );
1340 assert!(
1341 out.contains("\"tab_size\": 7, // my preferred width"),
1342 "inline comment on the unchanged field should be untouched:\n{out}"
1343 );
1344 let reparsed = crate::config::parse_config_jsonc(&out).unwrap();
1346 assert_eq!(
1347 reparsed.pointer("/editor/line_numbers"),
1348 Some(&serde_json::json!(false))
1349 );
1350 }
1351
1352 #[test]
1353 fn reconcile_appends_new_key_without_disturbing_comments() {
1354 let existing = "{\n // keep me\n \"editor\": { \"tab_size\": 2 }\n}\n";
1355 let target = serde_json::json!({
1356 "editor": { "tab_size": 2 },
1357 "theme": "dark"
1358 });
1359
1360 let out = reconcile_preserving_comments(existing, &target).unwrap();
1361 assert!(out.contains("// keep me"), "comment lost:\n{out}");
1362 let reparsed = crate::config::parse_config_jsonc(&out).unwrap();
1363 assert_eq!(reparsed.pointer("/theme"), Some(&serde_json::json!("dark")));
1364 assert_eq!(
1365 reparsed.pointer("/editor/tab_size"),
1366 Some(&serde_json::json!(2))
1367 );
1368 }
1369
1370 #[test]
1371 fn save_changes_to_layer_preserves_user_comments() {
1372 let (temp, resolver) = create_test_resolver();
1375
1376 let user_config_path = resolver.user_config_path();
1377 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1378 std::fs::write(
1379 &user_config_path,
1380 "{\n \
1381 // I like a 7-space tab in this project\n \
1382 \"editor\": {\n \
1383 \"tab_size\": 7 /* keep this */\n \
1384 }\n\
1385 }\n",
1386 )
1387 .unwrap();
1388
1389 let mut changes: std::collections::HashMap<String, serde_json::Value> =
1390 std::collections::HashMap::new();
1391 changes.insert("/editor/line_numbers".to_string(), serde_json::json!(false));
1392 resolver
1393 .save_changes_to_layer(
1394 &changes,
1395 &std::collections::HashSet::new(),
1396 ConfigLayer::User,
1397 )
1398 .unwrap();
1399
1400 let saved = std::fs::read_to_string(&user_config_path).unwrap();
1401 assert!(
1402 saved.contains("// I like a 7-space tab in this project"),
1403 "line comment must survive a settings save:\n{saved}"
1404 );
1405 assert!(
1406 saved.contains("/* keep this */"),
1407 "inline comment on the untouched field must survive:\n{saved}"
1408 );
1409
1410 let config = resolver.resolve().unwrap();
1412 assert_eq!(config.editor.tab_size, 7);
1413 assert!(!config.editor.line_numbers);
1414 drop(temp);
1415 }
1416
1417 #[test]
1418 fn reconcile_falls_back_for_non_object_root() {
1419 assert!(reconcile_preserving_comments("[1, 2, 3]", &serde_json::json!({"a": 1})).is_none());
1422 }
1423
1424 const REALISTIC_USER_CONFIG: &str = r#"{
1427 "version": 2,
1428 "theme": "builtin://dracula",
1429 "editor": {
1430 // stuff that's really thingy
1431 "hide_current_line_on_selection": true,
1432 "auto_read_only": false,
1433 "indentation_guide": "all"
1434 },
1435 // file explorer hooray:
1436 "file_explorer": {
1437 "show_hidden": true,
1438 "custom_ignore_patterns": ["*.log"]
1439 },
1440 "keybindings": [
1441 {
1442 "key": "=",
1443 "modifiers": ["alt"],
1444 "action": "next_window"
1445 }
1446 ],
1447 "languages": {
1448 "go": {
1449 "extensions": ["go"],
1450 "grammar": "go",
1451 "use_tabs": true,
1452 "tab_size": 8,
1453 "formatter": {
1454 "command": "gofmt",
1455 "stdin": true,
1456 "timeout_ms": 10000
1457 },
1458 "format_on_save": true
1459 }
1460 },
1461 "check_for_updates": false
1462}
1463"#;
1464
1465 #[test]
1471 fn realistic_commented_config_is_rejected_by_strict_json_but_accepted_by_loader() {
1472 assert!(
1474 serde_json::from_str::<serde_json::Value>(REALISTIC_USER_CONFIG).is_err(),
1475 "sanity: the sample must actually contain JSONC that strict JSON rejects"
1476 );
1477
1478 let v = crate::config::parse_config_jsonc(REALISTIC_USER_CONFIG)
1480 .expect("loader must accept legitimate JSON-with-comments");
1481 assert_eq!(
1482 v.pointer("/theme"),
1483 Some(&serde_json::json!("builtin://dracula"))
1484 );
1485 assert_eq!(
1486 v.pointer("/languages/go/tab_size"),
1487 Some(&serde_json::json!(8))
1488 );
1489 assert_eq!(
1490 v.pointer("/keybindings/0/action"),
1491 Some(&serde_json::json!("next_window"))
1492 );
1493 assert_eq!(
1494 v.pointer("/file_explorer/custom_ignore_patterns/0"),
1495 Some(&serde_json::json!("*.log"))
1496 );
1497 }
1498
1499 #[test]
1508 fn save_changes_does_not_clobber_unparseable_config() {
1509 let (temp, resolver) = create_test_resolver();
1510
1511 let user_config_path = resolver.user_config_path();
1512 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1513 let original = "{\n \"editor\": {\n \"tab_size\": 7\n";
1515 std::fs::write(&user_config_path, original).unwrap();
1516
1517 let mut changes = std::collections::HashMap::new();
1518 changes.insert("/editor/line_numbers".to_string(), serde_json::json!(false));
1519 let result = resolver.save_changes_to_layer(
1520 &changes,
1521 &std::collections::HashSet::new(),
1522 ConfigLayer::User,
1523 );
1524
1525 assert!(
1526 result.is_err(),
1527 "saving onto an unparseable config must error, not silently succeed and clobber it"
1528 );
1529 let after = std::fs::read_to_string(&user_config_path).unwrap();
1530 assert_eq!(
1531 after, original,
1532 "a failed save must leave the unparseable config file untouched"
1533 );
1534 drop(temp);
1535 }
1536
1537 #[test]
1539 fn save_with_baseline_does_not_clobber_unparseable_config() {
1540 let (temp, resolver) = create_test_resolver();
1541
1542 let user_config_path = resolver.user_config_path();
1543 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1544 let original = "{ \"editor\": { \"tab_size\": 7 oops not json";
1545 std::fs::write(&user_config_path, original).unwrap();
1546
1547 let baseline = Config::default();
1548 let mut current = Config::default();
1549 current.editor.tab_size = 3;
1550 let result = resolver.save_to_layer_with_baseline(¤t, &baseline, ConfigLayer::User);
1551
1552 assert!(result.is_err(), "must error on unparseable existing file");
1553 assert_eq!(
1554 std::fs::read_to_string(&user_config_path).unwrap(),
1555 original,
1556 "a failed save must leave the file untouched"
1557 );
1558 drop(temp);
1559 }
1560
1561 #[test]
1563 fn save_to_layer_does_not_clobber_unparseable_config() {
1564 let (temp, resolver) = create_test_resolver();
1565
1566 let user_config_path = resolver.user_config_path();
1567 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1568 let original = "{ broken";
1569 std::fs::write(&user_config_path, original).unwrap();
1570
1571 let mut config = Config::default();
1572 config.editor.tab_size = 3;
1573 let result = resolver.save_to_layer(&config, ConfigLayer::User);
1574
1575 assert!(result.is_err(), "must error on unparseable existing file");
1576 assert_eq!(
1577 std::fs::read_to_string(&user_config_path).unwrap(),
1578 original,
1579 "a failed save must leave the file untouched"
1580 );
1581 drop(temp);
1582 }
1583
1584 #[test]
1589 fn settings_save_preserves_full_realistic_config() {
1590 let (temp, resolver) = create_test_resolver();
1591
1592 let user_config_path = resolver.user_config_path();
1593 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1594 std::fs::write(&user_config_path, REALISTIC_USER_CONFIG).unwrap();
1595
1596 let mut changes = std::collections::HashMap::new();
1597 changes.insert(
1598 "/editor/auto_read_only".to_string(),
1599 serde_json::json!(true),
1600 );
1601 resolver
1602 .save_changes_to_layer(
1603 &changes,
1604 &std::collections::HashSet::new(),
1605 ConfigLayer::User,
1606 )
1607 .unwrap();
1608
1609 let saved = std::fs::read_to_string(&user_config_path).unwrap();
1610 assert!(
1612 saved.contains("// stuff that's really thingy"),
1613 "editor comment lost:\n{saved}"
1614 );
1615 assert!(
1616 saved.contains("// file explorer hooray:"),
1617 "file_explorer comment lost:\n{saved}"
1618 );
1619 let reparsed = crate::config::parse_config_jsonc(&saved).unwrap();
1621 assert_eq!(
1622 reparsed.pointer("/keybindings/0/action"),
1623 Some(&serde_json::json!("next_window")),
1624 "keybindings array lost:\n{saved}"
1625 );
1626 assert_eq!(
1627 reparsed.pointer("/languages/go/formatter/command"),
1628 Some(&serde_json::json!("gofmt")),
1629 "languages map lost:\n{saved}"
1630 );
1631 assert_eq!(
1632 reparsed.pointer("/theme"),
1633 Some(&serde_json::json!("builtin://dracula"))
1634 );
1635 assert_eq!(
1637 reparsed.pointer("/editor/auto_read_only"),
1638 Some(&serde_json::json!(true))
1639 );
1640 drop(temp);
1641 }
1642
1643 #[test]
1644 fn resolver_project_overrides_user() {
1645 let (temp, resolver) = create_test_resolver();
1646
1647 let user_config_path = resolver.user_config_path();
1649 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1650 std::fs::write(
1651 &user_config_path,
1652 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1653 )
1654 .unwrap();
1655
1656 let project_config_path = resolver.project_config_path();
1658 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1659 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1660
1661 let config = resolver.resolve().unwrap();
1662 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
1665 }
1666
1667 #[test]
1668 fn resolver_session_overrides_all() {
1669 let (temp, resolver) = create_test_resolver();
1670
1671 let user_config_path = resolver.user_config_path();
1673 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1674 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1675
1676 let project_config_path = resolver.project_config_path();
1678 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1679 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
1680
1681 let session_config_path = resolver.session_config_path();
1683 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1684
1685 let config = resolver.resolve().unwrap();
1686 assert_eq!(config.editor.tab_size, 16); drop(temp);
1688 }
1689
1690 #[test]
1691 fn layer_precedence_ordering() {
1692 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1693 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1694 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1695 }
1696
1697 #[test]
1698 fn save_to_system_layer_fails() {
1699 let (_temp, resolver) = create_test_resolver();
1700 let config = Config::default();
1701 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1702 assert!(result.is_err());
1703 }
1704
1705 #[test]
1706 fn resolver_loads_legacy_project_config() {
1707 let (temp, resolver) = create_test_resolver();
1708
1709 let working_dir = temp.path().join("project");
1711 let legacy_path = working_dir.join("config.json");
1712 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1713
1714 let config = resolver.resolve().unwrap();
1715 assert_eq!(config.editor.tab_size, 3);
1716 drop(temp);
1717 }
1718
1719 #[test]
1720 fn resolver_prefers_new_config_over_legacy() {
1721 let (temp, resolver) = create_test_resolver();
1722
1723 let working_dir = temp.path().join("project");
1725
1726 let legacy_path = working_dir.join("config.json");
1728 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1729
1730 let new_path = working_dir.join(".fresh").join("config.json");
1732 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1733 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1734
1735 let config = resolver.resolve().unwrap();
1736 assert_eq!(config.editor.tab_size, 5); drop(temp);
1738 }
1739
1740 #[test]
1741 fn load_with_layers_works() {
1742 let temp = TempDir::new().unwrap();
1743 let dir_context = DirectoryContext::for_testing(temp.path());
1744 let working_dir = temp.path().join("project");
1745 std::fs::create_dir_all(&working_dir).unwrap();
1746
1747 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1749 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1750
1751 let config = Config::load_with_layers(&dir_context, &working_dir);
1752 assert_eq!(config.editor.tab_size, 2);
1753 }
1754
1755 #[test]
1756 fn platform_config_overrides_user() {
1757 let (temp, resolver) = create_test_resolver();
1758
1759 let user_config_path = resolver.user_config_path();
1761 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1762 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1763
1764 if let Some(platform_path) = resolver.user_platform_config_path() {
1766 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1767
1768 let config = resolver.resolve().unwrap();
1769 assert_eq!(config.editor.tab_size, 6); }
1771 drop(temp);
1772 }
1773
1774 #[test]
1775 fn project_overrides_platform() {
1776 let (temp, resolver) = create_test_resolver();
1777
1778 let user_config_path = resolver.user_config_path();
1780 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1781 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1782
1783 if let Some(platform_path) = resolver.user_platform_config_path() {
1785 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1786 }
1787
1788 let project_config_path = resolver.project_config_path();
1790 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1791 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1792
1793 let config = resolver.resolve().unwrap();
1794 assert_eq!(config.editor.tab_size, 10); drop(temp);
1796 }
1797
1798 #[test]
1799 fn migration_adds_version() {
1800 let input = serde_json::json!({
1801 "editor": {"tab_size": 2}
1802 });
1803
1804 let migrated = migrate_config(input).unwrap();
1805
1806 assert_eq!(
1807 migrated.get("version"),
1808 Some(&serde_json::json!(CURRENT_CONFIG_VERSION))
1809 );
1810 }
1811
1812 #[test]
1813 fn migration_v1_to_v2_injects_remote_element() {
1814 let input = serde_json::json!({
1817 "version": 1,
1818 "editor": {
1819 "status_bar": {
1820 "left": ["{filename}", "{cursor}"]
1821 }
1822 }
1823 });
1824
1825 let migrated = migrate_config(input).unwrap();
1826
1827 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1828 let left = migrated
1829 .pointer("/editor/status_bar/left")
1830 .and_then(|v| v.as_array())
1831 .expect("status_bar.left should remain an array");
1832 assert_eq!(left[0], serde_json::json!("{remote}"));
1833 assert_eq!(left[1], serde_json::json!("{filename}"));
1834 assert_eq!(left[2], serde_json::json!("{cursor}"));
1835 }
1836
1837 #[test]
1838 fn migration_v1_to_v2_is_idempotent() {
1839 let input = serde_json::json!({
1842 "version": 1,
1843 "editor": {
1844 "status_bar": {
1845 "left": ["{filename}", "{remote}", "{cursor}"]
1846 }
1847 }
1848 });
1849
1850 let migrated = migrate_config(input).unwrap();
1851
1852 let left = migrated
1853 .pointer("/editor/status_bar/left")
1854 .and_then(|v| v.as_array())
1855 .unwrap();
1856 let remote_count = left
1857 .iter()
1858 .filter(|v| v.as_str() == Some("{remote}"))
1859 .count();
1860 assert_eq!(
1861 remote_count, 1,
1862 "migration should never duplicate an existing {{remote}} entry; left = {:?}",
1863 left
1864 );
1865 }
1866
1867 #[test]
1868 fn migration_v1_to_v2_leaves_default_users_alone() {
1869 let input = serde_json::json!({
1873 "version": 1,
1874 "editor": {"tab_size": 4}
1875 });
1876
1877 let migrated = migrate_config(input).unwrap();
1878
1879 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1880 assert!(
1881 migrated.pointer("/editor/status_bar").is_none(),
1882 "migration must not fabricate a status_bar object for users \
1883 who never overrode the default; migrated = {:?}",
1884 migrated
1885 );
1886 }
1887
1888 #[test]
1889 fn migration_renames_camelcase_keys() {
1890 let input = serde_json::json!({
1891 "editor": {
1892 "tabSize": 8,
1893 "lineNumbers": false
1894 }
1895 });
1896
1897 let migrated = migrate_config(input).unwrap();
1898
1899 let editor = migrated.get("editor").unwrap();
1900 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1901 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1902 assert!(editor.get("tabSize").is_none());
1903 assert!(editor.get("lineNumbers").is_none());
1904 }
1905
1906 #[test]
1907 fn migration_preserves_existing_snake_case() {
1908 let input = serde_json::json!({
1909 "version": 1,
1910 "editor": {"tab_size": 4}
1911 });
1912
1913 let migrated = migrate_config(input).unwrap();
1914
1915 let editor = migrated.get("editor").unwrap();
1916 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1917 }
1918
1919 #[test]
1920 fn resolver_loads_legacy_camelcase_config() {
1921 let (temp, resolver) = create_test_resolver();
1922
1923 let user_config_path = resolver.user_config_path();
1925 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1926 std::fs::write(
1927 &user_config_path,
1928 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1929 )
1930 .unwrap();
1931
1932 let config = resolver.resolve().unwrap();
1933 assert_eq!(config.editor.tab_size, 3);
1934 assert!(!config.editor.line_numbers);
1935 drop(temp);
1936 }
1937
1938 #[test]
1939 fn resolver_migrates_v1_status_bar_left_on_load() {
1940 let (temp, resolver) = create_test_resolver();
1945
1946 let user_config_path = resolver.user_config_path();
1947 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1948 std::fs::write(
1949 &user_config_path,
1950 r#"{
1951 "version": 1,
1952 "editor": {
1953 "status_bar": {
1954 "left": ["{filename}", "{cursor}"],
1955 "right": []
1956 }
1957 }
1958 }"#,
1959 )
1960 .unwrap();
1961
1962 let config = resolver.resolve().unwrap();
1963 let left = &config.editor.status_bar.left;
1964 assert_eq!(
1965 left.first().cloned(),
1966 Some(crate::config::StatusBarElement::RemoteIndicator),
1967 "resolver should inject RemoteIndicator at index 0 during v1→v2 \
1968 migration; left = {:?}",
1969 left
1970 );
1971 drop(temp);
1972 }
1973
1974 #[test]
1975 fn save_and_load_session() {
1976 let (_temp, resolver) = create_test_resolver();
1977
1978 let mut session = SessionConfig::new();
1979 session.set_theme(crate::config::ThemeName::from("dark"));
1980 session.set_editor_option(|e| e.tab_size = Some(2));
1981
1982 resolver.save_session(&session).unwrap();
1984
1985 let loaded = resolver.load_session().unwrap();
1987 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1988 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1989 }
1990
1991 #[test]
1992 fn clear_session_removes_file() {
1993 let (_temp, resolver) = create_test_resolver();
1994
1995 let mut session = SessionConfig::new();
1996 session.set_theme(crate::config::ThemeName::from("dark"));
1997
1998 resolver.save_session(&session).unwrap();
2000 assert!(resolver.session_config_path().exists());
2001
2002 resolver.clear_session().unwrap();
2003 assert!(!resolver.session_config_path().exists());
2004 }
2005
2006 #[test]
2007 fn load_session_returns_empty_when_no_file() {
2008 let (_temp, resolver) = create_test_resolver();
2009
2010 let session = resolver.load_session().unwrap();
2011 assert!(session.is_empty());
2012 }
2013
2014 #[test]
2015 fn session_affects_resolved_config() {
2016 let (_temp, resolver) = create_test_resolver();
2017
2018 let mut session = SessionConfig::new();
2020 session.set_editor_option(|e| e.tab_size = Some(16));
2021 resolver.save_session(&session).unwrap();
2022
2023 let config = resolver.resolve().unwrap();
2025 assert_eq!(config.editor.tab_size, 16);
2026 }
2027
2028 #[test]
2029 fn save_to_layer_writes_minimal_delta() {
2030 let (temp, resolver) = create_test_resolver();
2031
2032 let user_config_path = resolver.user_config_path();
2034 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2035 std::fs::write(
2036 &user_config_path,
2037 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
2038 )
2039 .unwrap();
2040
2041 let mut config = resolver.resolve().unwrap();
2043 assert_eq!(config.editor.tab_size, 2);
2044 assert!(!config.editor.line_numbers);
2045
2046 config.editor.tab_size = 8;
2048
2049 resolver
2051 .save_to_layer(&config, ConfigLayer::Project)
2052 .unwrap();
2053
2054 let project_config_path = resolver.project_config_write_path();
2056 let content = std::fs::read_to_string(&project_config_path).unwrap();
2057 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2058
2059 assert_eq!(
2061 json.get("editor").and_then(|e| e.get("tab_size")),
2062 Some(&serde_json::json!(8)),
2063 "Project config should contain tab_size override"
2064 );
2065
2066 assert!(
2068 json.get("editor")
2069 .and_then(|e| e.get("line_numbers"))
2070 .is_none(),
2071 "Project config should NOT contain line_numbers (it's inherited from user layer)"
2072 );
2073
2074 assert!(
2076 json.get("editor")
2077 .and_then(|e| e.get("scroll_offset"))
2078 .is_none(),
2079 "Project config should NOT contain scroll_offset (it's a system default)"
2080 );
2081
2082 drop(temp);
2083 }
2084
2085 #[test]
2091 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
2092 fn save_to_layer_removes_inherited_values() {
2093 let (temp, resolver) = create_test_resolver();
2094
2095 let user_config_path = resolver.user_config_path();
2097 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2098 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
2099
2100 let project_config_path = resolver.project_config_write_path();
2102 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
2103 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
2104
2105 let mut config = resolver.resolve().unwrap();
2107 assert_eq!(config.editor.tab_size, 8);
2108
2109 config.editor.tab_size = 2;
2111
2112 resolver
2114 .save_to_layer(&config, ConfigLayer::Project)
2115 .unwrap();
2116
2117 let content = std::fs::read_to_string(&project_config_path).unwrap();
2119 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2120
2121 assert!(
2123 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
2124 "Project config should NOT contain tab_size when it matches user layer"
2125 );
2126
2127 drop(temp);
2128 }
2129
2130 #[test]
2138 fn issue_630_save_to_file_strips_settings_matching_defaults() {
2139 let (_temp, resolver) = create_test_resolver();
2140
2141 let user_config_path = resolver.user_config_path();
2143 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2144 std::fs::write(
2145 &user_config_path,
2146 r#"{
2147 "theme": "dracula",
2148 "editor": {
2149 "tab_size": 2
2150 }
2151 }"#,
2152 )
2153 .unwrap();
2154
2155 let mut config = resolver.resolve().unwrap();
2157 assert_eq!(config.theme.0, "dracula");
2158 assert_eq!(config.editor.tab_size, 2);
2159
2160 if let Some(lsp_configs) = config.lsp.get_mut("python") {
2162 for c in lsp_configs.as_mut_slice().iter_mut() {
2163 c.enabled = false;
2164 }
2165 }
2166
2167 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2169
2170 let content = std::fs::read_to_string(&user_config_path).unwrap();
2172 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
2173
2174 eprintln!(
2175 "Saved config:\n{}",
2176 serde_json::to_string_pretty(&json).unwrap()
2177 );
2178
2179 assert_eq!(
2181 json.get("theme").and_then(|v| v.as_str()),
2182 Some("dracula"),
2183 "Theme should be saved (differs from default)"
2184 );
2185 assert_eq!(
2186 json.get("editor")
2187 .and_then(|e| e.get("tab_size"))
2188 .and_then(|v| v.as_u64()),
2189 Some(2),
2190 "tab_size should be saved (differs from default)"
2191 );
2192 assert_eq!(
2193 json.get("lsp")
2194 .and_then(|l| l.get("python"))
2195 .and_then(|p| p.get("enabled"))
2196 .and_then(|v| v.as_bool()),
2197 Some(false),
2198 "lsp.python.enabled should be saved (differs from default)"
2199 );
2200
2201 let reloaded = resolver.resolve().unwrap();
2203 assert_eq!(reloaded.theme.0, "dracula");
2204 assert_eq!(reloaded.editor.tab_size, 2);
2205 assert!(!reloaded.lsp["python"].as_slice()[0].enabled);
2206 assert_eq!(reloaded.lsp["python"].as_slice()[0].command, "pylsp");
2208 }
2209
2210 #[test]
2217 fn toggle_lsp_preserves_command() {
2218 let (_temp, resolver) = create_test_resolver();
2219 let user_config_path = resolver.user_config_path();
2220 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2221
2222 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2224
2225 let config = resolver.resolve().unwrap();
2227 let original_command = config.lsp["python"].as_slice()[0].command.clone();
2228 assert!(
2229 !original_command.is_empty(),
2230 "Default python LSP should have a command"
2231 );
2232
2233 let mut config = resolver.resolve().unwrap();
2235 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = false;
2236 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2237
2238 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2240 assert!(
2241 !saved_content.contains(r#""command""#),
2242 "Saved config should not contain 'command' field. File content: {}",
2243 saved_content
2244 );
2245 assert!(
2246 !saved_content.contains(r#""args""#),
2247 "Saved config should not contain 'args' field. File content: {}",
2248 saved_content
2249 );
2250
2251 let mut config = resolver.resolve().unwrap();
2253 assert!(!config.lsp["python"].as_slice()[0].enabled);
2254 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
2255 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2256
2257 let config = resolver.resolve().unwrap();
2259 assert_eq!(
2260 config.lsp["python"].as_slice()[0].command,
2261 original_command,
2262 "Command should be preserved after toggling enabled. Got: '{}'",
2263 config.lsp["python"].as_slice()[0].command
2264 );
2265 }
2266
2267 #[test]
2278 fn issue_631_disabled_lsp_without_command_should_be_valid() {
2279 let (_temp, resolver) = create_test_resolver();
2280
2281 let user_config_path = resolver.user_config_path();
2283 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2284 std::fs::write(
2285 &user_config_path,
2286 r#"{
2287 "lsp": {
2288 "json": { "enabled": false },
2289 "python": { "enabled": false },
2290 "toml": { "enabled": false }
2291 },
2292 "theme": "dracula"
2293 }"#,
2294 )
2295 .unwrap();
2296
2297 let result = resolver.resolve();
2299
2300 assert!(
2303 result.is_ok(),
2304 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
2305 Got parse error: {:?}",
2306 result.err()
2307 );
2308
2309 let config = result.unwrap();
2311 assert_eq!(
2312 config.theme.0, "dracula",
2313 "Theme should be 'dracula' from config file"
2314 );
2315 }
2316
2317 #[test]
2319 fn loading_lsp_without_command_uses_default() {
2320 let (_temp, resolver) = create_test_resolver();
2321 let user_config_path = resolver.user_config_path();
2322 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2323
2324 std::fs::write(
2326 &user_config_path,
2327 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
2328 )
2329 .unwrap();
2330
2331 let config = resolver.resolve().unwrap();
2333 assert_eq!(
2334 config.lsp["rust"].as_slice()[0].command,
2335 "rust-analyzer",
2336 "Command should come from defaults when not in file. Got: '{}'",
2337 config.lsp["rust"].as_slice()[0].command
2338 );
2339 assert!(
2340 !config.lsp["rust"].as_slice()[0].enabled,
2341 "enabled should be false from file"
2342 );
2343 }
2344
2345 #[test]
2351 fn settings_ui_toggle_lsp_preserves_command() {
2352 let (_temp, resolver) = create_test_resolver();
2353 let user_config_path = resolver.user_config_path();
2354 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2355
2356 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2358
2359 let config = resolver.resolve().unwrap();
2361 assert_eq!(
2362 config.lsp["rust"].as_slice()[0].command,
2363 "rust-analyzer",
2364 "Default rust command should be rust-analyzer"
2365 );
2366 assert!(
2367 config.lsp["rust"].as_slice()[0].enabled,
2368 "Default rust enabled should be true"
2369 );
2370
2371 let mut changes = std::collections::HashMap::new();
2374 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
2375 let deletions = std::collections::HashSet::new();
2376
2377 resolver
2379 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
2380 .unwrap();
2381
2382 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2384 eprintln!("After disable, file contains:\n{}", saved_content);
2385
2386 let reloaded = resolver.resolve().unwrap();
2388 assert_eq!(
2389 reloaded.lsp["rust"].as_slice()[0].command,
2390 "rust-analyzer",
2391 "Command should be preserved after save/reload (disabled). Got: '{}'",
2392 reloaded.lsp["rust"].as_slice()[0].command
2393 );
2394 assert!(
2395 !reloaded.lsp["rust"].as_slice()[0].enabled,
2396 "rust should be disabled"
2397 );
2398
2399 let mut changes = std::collections::HashMap::new();
2401 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
2402 let deletions = std::collections::HashSet::new();
2403
2404 resolver
2406 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
2407 .unwrap();
2408
2409 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2411 eprintln!("After re-enable, file contains:\n{}", saved_content);
2412
2413 let final_config = resolver.resolve().unwrap();
2415 assert_eq!(
2416 final_config.lsp["rust"].as_slice()[0].command,
2417 "rust-analyzer",
2418 "Command should be preserved after toggle cycle. Got: '{}'",
2419 final_config.lsp["rust"].as_slice()[0].command
2420 );
2421 assert!(
2422 final_config.lsp["rust"].as_slice()[0].enabled,
2423 "rust should be enabled"
2424 );
2425 }
2426
2427 #[test]
2438 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
2439 let (_temp, resolver) = create_test_resolver();
2440 let user_config_path = resolver.user_config_path();
2441 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2442
2443 std::fs::write(
2446 &user_config_path,
2447 r#"{
2448 "lsp": {
2449 "rust-analyzer": {
2450 "enabled": true,
2451 "command": "rust-analyzer",
2452 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
2453 "languages": ["rust"]
2454 }
2455 }
2456 }"#,
2457 )
2458 .unwrap();
2459
2460 let config = resolver.resolve().unwrap();
2462
2463 assert!(
2465 config.lsp.contains_key("rust-analyzer"),
2466 "Config should contain manually-added 'rust-analyzer' LSP entry"
2467 );
2468 let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
2469 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
2470 assert_eq!(
2471 rust_analyzer.command, "rust-analyzer",
2472 "rust-analyzer command should be preserved"
2473 );
2474 assert_eq!(
2475 rust_analyzer.args,
2476 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
2477 "rust-analyzer args should be preserved"
2478 );
2479
2480 let mut config_json = serde_json::to_value(&config).unwrap();
2483 *config_json
2484 .pointer_mut("/editor/tab_size")
2485 .expect("path should exist") = serde_json::json!(2);
2486 let modified_config: crate::config::Config =
2487 serde_json::from_value(config_json).expect("should deserialize");
2488
2489 resolver
2491 .save_to_layer(&modified_config, ConfigLayer::User)
2492 .unwrap();
2493
2494 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2496 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2497
2498 eprintln!(
2499 "Issue #806 - Saved config after changing tab_size:\n{}",
2500 serde_json::to_string_pretty(&saved_json).unwrap()
2501 );
2502
2503 assert!(
2505 saved_json.get("lsp").is_some(),
2506 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
2507 File content: {}",
2508 saved_content
2509 );
2510
2511 assert!(
2512 saved_json
2513 .get("lsp")
2514 .and_then(|l| l.get("rust-analyzer"))
2515 .is_some(),
2516 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
2517 File content: {}",
2518 saved_content
2519 );
2520
2521 let saved_args = saved_json
2523 .get("lsp")
2524 .and_then(|l| l.get("rust-analyzer"))
2525 .and_then(|r| r.get("args"));
2526 assert!(
2527 saved_args.is_some(),
2528 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
2529 saved_content
2530 );
2531 assert_eq!(
2532 saved_args.unwrap(),
2533 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
2534 "BUG #806: Custom args should be preserved exactly"
2535 );
2536
2537 assert_eq!(
2539 saved_json
2540 .get("editor")
2541 .and_then(|e| e.get("tab_size"))
2542 .and_then(|v| v.as_u64()),
2543 Some(2),
2544 "tab_size should be saved"
2545 );
2546
2547 let reloaded = resolver.resolve().unwrap();
2549 assert_eq!(
2550 reloaded.editor.tab_size, 2,
2551 "tab_size change should be persisted"
2552 );
2553 assert!(
2554 reloaded.lsp.contains_key("rust-analyzer"),
2555 "BUG #806: rust-analyzer should still exist after reload"
2556 );
2557 let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
2558 assert_eq!(
2559 reloaded_ra.args,
2560 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
2561 "BUG #806: Custom args should survive save/reload cycle"
2562 );
2563 }
2564
2565 #[test]
2570 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
2571 let (_temp, resolver) = create_test_resolver();
2572 let user_config_path = resolver.user_config_path();
2573 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2574
2575 std::fs::write(
2577 &user_config_path,
2578 r#"{
2579 "theme": "dracula",
2580 "lsp": {
2581 "my-custom-lsp": {
2582 "enabled": true,
2583 "command": "/usr/local/bin/my-custom-lsp",
2584 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
2585 "languages": ["mycustomlang"]
2586 }
2587 },
2588 "languages": {
2589 "mycustomlang": {
2590 "extensions": [".mcl"],
2591 "grammar": "mycustomlang"
2592 }
2593 }
2594 }"#,
2595 )
2596 .unwrap();
2597
2598 let config = resolver.resolve().unwrap();
2600 assert!(
2601 config.lsp.contains_key("my-custom-lsp"),
2602 "Custom LSP entry should be loaded"
2603 );
2604 assert!(
2605 config.languages.contains_key("mycustomlang"),
2606 "Custom language should be loaded"
2607 );
2608
2609 let mut config_json = serde_json::to_value(&config).unwrap();
2611 *config_json
2612 .pointer_mut("/editor/line_numbers")
2613 .expect("path should exist") = serde_json::json!(false);
2614 let modified_config: crate::config::Config =
2615 serde_json::from_value(config_json).expect("should deserialize");
2616
2617 resolver
2619 .save_to_layer(&modified_config, ConfigLayer::User)
2620 .unwrap();
2621
2622 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2624 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2625
2626 eprintln!(
2627 "Saved config:\n{}",
2628 serde_json::to_string_pretty(&saved_json).unwrap()
2629 );
2630
2631 assert!(
2633 saved_json
2634 .get("lsp")
2635 .and_then(|l| l.get("my-custom-lsp"))
2636 .is_some(),
2637 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
2638 saved_content
2639 );
2640
2641 assert!(
2643 saved_json
2644 .get("languages")
2645 .and_then(|l| l.get("mycustomlang"))
2646 .is_some(),
2647 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
2648 saved_content
2649 );
2650
2651 let reloaded = resolver.resolve().unwrap();
2653 assert!(
2654 reloaded.lsp.contains_key("my-custom-lsp"),
2655 "Custom LSP should survive save/reload"
2656 );
2657 assert!(
2658 reloaded.languages.contains_key("mycustomlang"),
2659 "Custom language should survive save/reload"
2660 );
2661 assert!(
2662 !reloaded.editor.line_numbers,
2663 "line_numbers change should be applied"
2664 );
2665 }
2666
2667 #[test]
2680 fn issue_806_external_file_modification_lost_on_ui_save() {
2681 let (_temp, resolver) = create_test_resolver();
2682 let user_config_path = resolver.user_config_path();
2683 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2684
2685 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2687
2688 let config_at_startup = resolver.resolve().unwrap();
2690 assert_eq!(config_at_startup.theme.0, "monokai");
2691 assert!(
2692 !config_at_startup.lsp.contains_key("rust-analyzer"),
2693 "No custom LSP at startup"
2694 );
2695
2696 std::fs::write(
2699 &user_config_path,
2700 r#"{
2701 "theme": "monokai",
2702 "lsp": {
2703 "rust-analyzer": {
2704 "enabled": true,
2705 "command": "rust-analyzer",
2706 "args": ["--log-file", "/tmp/ra.log"]
2707 }
2708 }
2709 }"#,
2710 )
2711 .unwrap();
2712
2713 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2717 *config_json
2718 .pointer_mut("/editor/tab_size")
2719 .expect("path should exist") = serde_json::json!(2);
2720 let modified_config: crate::config::Config =
2721 serde_json::from_value(config_json).expect("should deserialize");
2722
2723 resolver
2727 .save_to_layer(&modified_config, ConfigLayer::User)
2728 .unwrap();
2729
2730 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2732 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2733
2734 eprintln!(
2735 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2736 serde_json::to_string_pretty(&saved_json).unwrap()
2737 );
2738
2739 assert!(
2745 saved_json.get("lsp").is_some(),
2746 "BUG #806: External edits to config.json were lost! \
2747 The 'lsp' section added while Fresh was running should be preserved. \
2748 Saved content: {}",
2749 saved_content
2750 );
2751
2752 assert!(
2753 saved_json
2754 .get("lsp")
2755 .and_then(|l| l.get("rust-analyzer"))
2756 .is_some(),
2757 "BUG #806: rust-analyzer config should be preserved"
2758 );
2759 }
2760
2761 #[test]
2767 fn issue_806_concurrent_modification_scenario() {
2768 let (_temp, resolver) = create_test_resolver();
2769 let user_config_path = resolver.user_config_path();
2770 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2771
2772 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2774
2775 let mut config = resolver.resolve().unwrap();
2777
2778 config.editor.tab_size = 8;
2780
2781 std::fs::write(
2783 &user_config_path,
2784 r#"{
2785 "lsp": {
2786 "custom-lsp": {
2787 "enabled": true,
2788 "command": "/usr/bin/custom-lsp"
2789 }
2790 }
2791 }"#,
2792 )
2793 .unwrap();
2794
2795 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2798
2799 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2801 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2802
2803 eprintln!(
2804 "Concurrent modification scenario result:\n{}",
2805 serde_json::to_string_pretty(&saved_json).unwrap()
2806 );
2807
2808 assert_eq!(
2810 saved_json
2811 .get("editor")
2812 .and_then(|e| e.get("tab_size"))
2813 .and_then(|v| v.as_u64()),
2814 Some(8),
2815 "Our tab_size change should be saved"
2816 );
2817
2818 let lsp_preserved = saved_json.get("lsp").is_some();
2824 if !lsp_preserved {
2825 eprintln!(
2826 "NOTE: Concurrent file modifications are lost with current implementation. \
2827 This is expected behavior but could be improved with read-modify-write pattern."
2828 );
2829 }
2830 }
2831
2832 #[test]
2842 fn save_to_layer_changing_to_default_value_should_persist() {
2843 let (_temp, resolver) = create_test_resolver();
2844 let user_config_path = resolver.user_config_path();
2845 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2846
2847 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2849
2850 let baseline = resolver.resolve().unwrap();
2852 assert_eq!(
2853 baseline.theme.0, "dracula",
2854 "Theme should be 'dracula' from file"
2855 );
2856
2857 let mut config = baseline.clone();
2859 config.theme = crate::config::ThemeName::from("high-contrast");
2860
2861 resolver
2863 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2864 .unwrap();
2865
2866 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2868 eprintln!(
2869 "Saved config after changing to default theme:\n{}",
2870 saved_content
2871 );
2872
2873 let reloaded = resolver.resolve().unwrap();
2875
2876 assert_eq!(
2878 reloaded.theme.0, "high-contrast",
2879 "Theme should be 'high-contrast' after changing to default and saving. \
2880 With save_to_layer_with_baseline, the theme field should be removed from file \
2881 so the default applies. File content: {}",
2882 saved_content
2883 );
2884 }
2885
2886 #[test]
2889 fn universal_lsp_round_trip_via_config_resolver() {
2890 let (_temp, resolver) = create_test_resolver();
2891 let user_config_path = resolver.user_config_path();
2892 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2893
2894 std::fs::write(
2896 &user_config_path,
2897 r#"{
2898 "universal_lsp": {
2899 "quicklsp": { "enabled": true, "auto_start": true }
2900 }
2901 }"#,
2902 )
2903 .unwrap();
2904
2905 let config = resolver.resolve().unwrap();
2906
2907 assert!(config.universal_lsp.contains_key("quicklsp"));
2909 let server = &config.universal_lsp["quicklsp"].as_slice()[0];
2910 assert!(server.enabled, "User override should enable quicklsp");
2911 assert!(server.auto_start, "User override should enable auto_start");
2912 assert_eq!(
2913 server.command, "quicklsp",
2914 "Command should come from defaults"
2915 );
2916 }
2917
2918 #[test]
2920 fn universal_lsp_custom_server_merges_with_defaults() {
2921 let (_temp, resolver) = create_test_resolver();
2922 let user_config_path = resolver.user_config_path();
2923 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2924
2925 std::fs::write(
2926 &user_config_path,
2927 r#"{
2928 "universal_lsp": {
2929 "my-universal-server": {
2930 "command": "my-server-bin",
2931 "enabled": true
2932 }
2933 }
2934 }"#,
2935 )
2936 .unwrap();
2937
2938 let config = resolver.resolve().unwrap();
2939
2940 assert!(
2942 config.universal_lsp.contains_key("my-universal-server"),
2943 "Custom universal server should be loaded"
2944 );
2945 assert_eq!(
2946 config.universal_lsp["my-universal-server"].as_slice()[0].command,
2947 "my-server-bin"
2948 );
2949
2950 assert!(
2952 config.universal_lsp.contains_key("quicklsp"),
2953 "Default quicklsp should be preserved when adding custom servers"
2954 );
2955 }
2956
2957 #[test]
2961 fn universal_lsp_partial_config_round_trip() {
2962 use crate::partial_config::PartialConfig;
2963
2964 let mut config = Config::default();
2965 if let Some(quicklsp) = config.universal_lsp.get_mut("quicklsp") {
2967 quicklsp.as_mut_slice()[0].enabled = true;
2968 }
2969
2970 let partial = PartialConfig::from(&config);
2972 let resolved = partial.resolve();
2973
2974 assert!(
2976 resolved.universal_lsp.contains_key("quicklsp"),
2977 "quicklsp should survive Config -> PartialConfig -> Config round trip"
2978 );
2979 assert!(
2980 resolved.universal_lsp["quicklsp"].as_slice()[0].enabled,
2981 "quicklsp enabled state should be preserved through round trip"
2982 );
2983 }
2984}