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
103fn 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
174pub const CURRENT_CONFIG_VERSION: u32 = 2;
181
182pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
184 let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
185
186 if version < 1 {
188 value = migrate_v0_to_v1(value)?;
189 }
190 if version < 2 {
191 value = migrate_v1_to_v2(value)?;
192 }
193
194 Ok(value)
195}
196
197fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
200 if let Value::Object(ref mut map) = value {
201 map.insert("version".to_string(), Value::Number(1.into()));
203
204 if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
206 if let Some(val) = editor_map.remove("tabSize") {
208 editor_map.entry("tab_size").or_insert(val);
209 }
210 if let Some(val) = editor_map.remove("lineNumbers") {
212 editor_map.entry("line_numbers").or_insert(val);
213 }
214 }
215 }
216 Ok(value)
217}
218
219fn migrate_v1_to_v2(mut value: Value) -> Result<Value, ConfigError> {
228 if let Value::Object(ref mut map) = value {
229 map.insert("version".to_string(), Value::Number(2.into()));
230
231 let left = map
232 .get_mut("editor")
233 .and_then(|editor| editor.as_object_mut())
234 .and_then(|editor| editor.get_mut("status_bar"))
235 .and_then(|status_bar| status_bar.as_object_mut())
236 .and_then(|status_bar| status_bar.get_mut("left"))
237 .and_then(|left| left.as_array_mut());
238
239 if let Some(left) = left {
240 let already_present = left.iter().any(|v| v.as_str() == Some("{remote}"));
241 if !already_present {
242 left.insert(0, Value::String("{remote}".to_string()));
243 }
244 }
245 }
246 Ok(value)
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum ConfigLayer {
252 System,
254 User,
256 Project,
258 Session,
260}
261
262impl ConfigLayer {
263 pub fn precedence(self) -> u8 {
265 match self {
266 Self::System => 0,
267 Self::User => 1,
268 Self::Project => 2,
269 Self::Session => 3,
270 }
271 }
272}
273
274pub struct ConfigResolver {
279 dir_context: DirectoryContext,
280 working_dir: PathBuf,
281}
282
283impl ConfigResolver {
284 pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
286 Self {
287 dir_context,
288 working_dir,
289 }
290 }
291
292 pub fn resolve(&self) -> Result<Config, ConfigError> {
299 let mut merged = self.load_session_layer()?.unwrap_or_default();
301
302 if let Some(project_partial) = self.load_project_layer()? {
304 tracing::debug!("Loaded project config layer");
305 merged.merge_from(&project_partial);
306 }
307
308 if let Some(platform_partial) = self.load_user_platform_layer()? {
310 tracing::debug!("Loaded user platform config layer");
311 merged.merge_from(&platform_partial);
312 }
313
314 if let Some(user_partial) = self.load_user_layer()? {
316 tracing::debug!("Loaded user config layer");
317 merged.merge_from(&user_partial);
318 }
319
320 Ok(merged.resolve())
322 }
323
324 pub fn user_config_path(&self) -> PathBuf {
326 self.dir_context.config_path()
327 }
328
329 pub fn project_config_path(&self) -> PathBuf {
332 let new_path = self.working_dir.join(".fresh").join("config.json");
333 if new_path.exists() {
334 return new_path;
335 }
336 let legacy_path = self.working_dir.join("config.json");
338 if legacy_path.exists() {
339 return legacy_path;
340 }
341 new_path
343 }
344
345 pub fn project_config_write_path(&self) -> PathBuf {
347 self.working_dir.join(".fresh").join("config.json")
348 }
349
350 pub fn session_config_path(&self) -> PathBuf {
352 self.working_dir.join(".fresh").join("session.json")
353 }
354
355 fn platform_config_filename() -> Option<&'static str> {
357 if cfg!(target_os = "linux") {
358 Some("config_linux.json")
359 } else if cfg!(target_os = "macos") {
360 Some("config_macos.json")
361 } else if cfg!(target_os = "windows") {
362 Some("config_windows.json")
363 } else {
364 None
365 }
366 }
367
368 pub fn user_platform_config_path(&self) -> Option<PathBuf> {
370 Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
371 }
372
373 pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
375 self.load_layer_from_path(&self.user_config_path())
376 }
377
378 pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
380 if let Some(path) = self.user_platform_config_path() {
381 self.load_layer_from_path(&path)
382 } else {
383 Ok(None)
384 }
385 }
386
387 pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
389 self.load_layer_from_path(&self.project_config_path())
390 }
391
392 pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
394 self.load_layer_from_path(&self.session_config_path())
395 }
396
397 fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
399 if !path.exists() {
400 return Ok(None);
401 }
402
403 let content = std::fs::read_to_string(path)
404 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
405
406 let value: Value = serde_json::from_str(&content)
408 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
409
410 let migrated = migrate_config(value)?;
412
413 let partial: PartialConfig = serde_json::from_value(migrated)
415 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
416
417 Ok(Some(partial))
418 }
419
420 pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
422 if layer == ConfigLayer::System {
423 return Err(ConfigError::ValidationError(
424 "Cannot write to System layer".to_string(),
425 ));
426 }
427
428 let parent_partial = self.resolve_up_to_layer(layer)?;
430
431 let parent = PartialConfig::from(&parent_partial.resolve());
435
436 let current = PartialConfig::from(config);
438
439 let delta = diff_partial_config(¤t, &parent);
441
442 let path = match layer {
444 ConfigLayer::User => self.user_config_path(),
445 ConfigLayer::Project => self.project_config_write_path(),
446 ConfigLayer::Session => self.session_config_path(),
447 ConfigLayer::System => unreachable!(),
448 };
449
450 if let Some(parent_dir) = path.parent() {
452 std::fs::create_dir_all(parent_dir)
453 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
454 }
455
456 let existing: PartialConfig = if path.exists() {
459 let content = std::fs::read_to_string(&path)
460 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
461 serde_json::from_str(&content).unwrap_or_default()
462 } else {
463 PartialConfig::default()
464 };
465
466 let mut merged = delta;
468 merged.merge_from(&existing);
469
470 let merged_value = serde_json::to_value(&merged)
472 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
473 let stripped_nulls = strip_nulls(merged_value).unwrap_or(Value::Object(Default::default()));
474 let clean_merged =
475 strip_empty_defaults(stripped_nulls).unwrap_or(Value::Object(Default::default()));
476
477 let json = serde_json::to_string_pretty(&clean_merged)
478 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
479 std::fs::write(&path, json)
480 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
481
482 Ok(())
483 }
484
485 pub fn save_to_layer_with_baseline(
495 &self,
496 current: &Config,
497 baseline: &Config,
498 layer: ConfigLayer,
499 ) -> Result<(), ConfigError> {
500 if layer == ConfigLayer::System {
501 return Err(ConfigError::ValidationError(
502 "Cannot write to System layer".to_string(),
503 ));
504 }
505
506 let parent_partial = self.resolve_up_to_layer(layer)?;
508 let parent = PartialConfig::from(&parent_partial.resolve());
509
510 let current_json = serde_json::to_value(current)
512 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
513 let baseline_json = serde_json::to_value(baseline)
514 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
515 let parent_json = serde_json::to_value(&parent)
516 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
517
518 let changed_paths = find_changed_paths(&baseline_json, ¤t_json);
520
521 let path = match layer {
523 ConfigLayer::User => self.user_config_path(),
524 ConfigLayer::Project => self.project_config_write_path(),
525 ConfigLayer::Session => self.session_config_path(),
526 ConfigLayer::System => unreachable!(),
527 };
528
529 if let Some(parent_dir) = path.parent() {
531 std::fs::create_dir_all(parent_dir)
532 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
533 }
534
535 let mut result: Value = if path.exists() {
537 let content = std::fs::read_to_string(&path)
538 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
539 serde_json::from_str(&content).unwrap_or(Value::Object(Default::default()))
540 } else {
541 Value::Object(Default::default())
542 };
543
544 for pointer in &changed_paths {
548 let current_val = current_json.pointer(pointer);
549 let parent_val = parent_json.pointer(pointer);
550
551 if current_val == parent_val {
552 remove_json_pointer(&mut result, pointer);
554 } else if let Some(val) = current_val {
555 set_json_pointer(&mut result, pointer, val.clone());
557 }
558 }
559
560 let stripped = strip_nulls(result).unwrap_or(Value::Object(Default::default()));
562 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
563
564 let json = serde_json::to_string_pretty(&clean)
565 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
566 std::fs::write(&path, json)
567 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
568
569 Ok(())
570 }
571
572 pub fn save_changes_to_layer(
577 &self,
578 changes: &std::collections::HashMap<String, serde_json::Value>,
579 deletions: &std::collections::HashSet<String>,
580 layer: ConfigLayer,
581 ) -> Result<(), ConfigError> {
582 if layer == ConfigLayer::System {
583 return Err(ConfigError::ValidationError(
584 "Cannot write to System layer".to_string(),
585 ));
586 }
587
588 let path = match layer {
590 ConfigLayer::User => self.user_config_path(),
591 ConfigLayer::Project => self.project_config_write_path(),
592 ConfigLayer::Session => self.session_config_path(),
593 ConfigLayer::System => unreachable!(),
594 };
595
596 if let Some(parent_dir) = path.parent() {
598 std::fs::create_dir_all(parent_dir)
599 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
600 }
601
602 let mut config_value: Value = if path.exists() {
604 let content = std::fs::read_to_string(&path)
605 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
606 serde_json::from_str(&content).unwrap_or(Value::Object(Default::default()))
607 } else {
608 Value::Object(Default::default())
609 };
610
611 for pointer in deletions {
613 remove_json_pointer(&mut config_value, pointer);
614 }
615
616 for (pointer, value) in changes {
618 set_json_pointer(&mut config_value, pointer, value.clone());
619 }
620
621 let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
623 ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
624 })?;
625
626 let stripped = strip_nulls(config_value).unwrap_or(Value::Object(Default::default()));
628 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
629
630 let json = serde_json::to_string_pretty(&clean)
631 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
632 std::fs::write(&path, json)
633 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
634
635 Ok(())
636 }
637
638 pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
640 let path = self.session_config_path();
641
642 if let Some(parent_dir) = path.parent() {
644 std::fs::create_dir_all(parent_dir)
645 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
646 }
647
648 let json = serde_json::to_string_pretty(session)
649 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
650 std::fs::write(&path, json)
651 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
652
653 tracing::debug!("Saved session config to {}", path.display());
654 Ok(())
655 }
656
657 pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
659 match self.load_session_layer()? {
660 Some(partial) => Ok(SessionConfig::from(partial)),
661 None => Ok(SessionConfig::new()),
662 }
663 }
664
665 pub fn clear_session(&self) -> Result<(), ConfigError> {
667 let path = self.session_config_path();
668 if path.exists() {
669 std::fs::remove_file(&path)
670 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
671 tracing::debug!("Cleared session config at {}", path.display());
672 }
673 Ok(())
674 }
675
676 fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
679 let mut merged = PartialConfig::default();
680
681 if layer == ConfigLayer::Session {
687 if let Some(project) = self.load_project_layer()? {
689 merged = project;
690 }
691 if let Some(platform) = self.load_user_platform_layer()? {
692 merged.merge_from(&platform);
693 }
694 if let Some(user) = self.load_user_layer()? {
695 merged.merge_from(&user);
696 }
697 } else if layer == ConfigLayer::Project {
698 if let Some(platform) = self.load_user_platform_layer()? {
700 merged = platform;
701 }
702 if let Some(user) = self.load_user_layer()? {
703 merged.merge_from(&user);
704 }
705 }
706 Ok(merged)
709 }
710
711 pub fn get_layer_sources(
714 &self,
715 ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
716 use std::collections::HashMap;
717
718 let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
719
720 if let Some(session) = self.load_session_layer()? {
725 let json = serde_json::to_value(&session).unwrap_or_default();
726 collect_paths(&json, "", &mut |path| {
727 sources.insert(path, ConfigLayer::Session);
728 });
729 }
730
731 if let Some(project) = self.load_project_layer()? {
732 let json = serde_json::to_value(&project).unwrap_or_default();
733 collect_paths(&json, "", &mut |path| {
734 sources.entry(path).or_insert(ConfigLayer::Project);
735 });
736 }
737
738 if let Some(user) = self.load_user_layer()? {
739 let json = serde_json::to_value(&user).unwrap_or_default();
740 collect_paths(&json, "", &mut |path| {
741 sources.entry(path).or_insert(ConfigLayer::User);
742 });
743 }
744
745 Ok(sources)
748 }
749}
750
751fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
753where
754 F: FnMut(String),
755{
756 match value {
757 Value::Object(map) => {
758 for (key, val) in map {
759 let path = if prefix.is_empty() {
760 format!("/{}", key)
761 } else {
762 format!("{}/{}", prefix, key)
763 };
764 collect_paths(val, &path, collector);
765 }
766 }
767 Value::Null => {} _ => {
769 collector(prefix.to_string());
771 }
772 }
773}
774
775fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
778 let current_json = serde_json::to_value(current).unwrap_or_default();
780 let parent_json = serde_json::to_value(parent).unwrap_or_default();
781
782 let diff = json_diff(&parent_json, ¤t_json);
783
784 serde_json::from_value(diff).unwrap_or_default()
786}
787
788impl Config {
789 fn system_config_paths() -> Vec<PathBuf> {
794 let mut paths = Vec::with_capacity(2);
795
796 #[cfg(target_os = "macos")]
798 if let Some(home) = dirs::home_dir() {
799 let path = home.join(".config").join("fresh").join(Config::FILENAME);
800 if path.exists() {
801 paths.push(path);
802 }
803 }
804
805 if let Some(config_dir) = dirs::config_dir() {
807 let path = config_dir.join("fresh").join(Config::FILENAME);
808 if !paths.contains(&path) && path.exists() {
809 paths.push(path);
810 }
811 }
812
813 paths
814 }
815
816 fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
824 let local = Self::local_config_path(working_dir);
825 let mut paths = Vec::with_capacity(3);
826
827 if local.exists() {
828 paths.push(local);
829 }
830
831 paths.extend(Self::system_config_paths());
832 paths
833 }
834
835 pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
839 Self::config_search_paths(working_dir).into_iter().next()
840 }
841
842 pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
847 let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
848 match resolver.resolve() {
849 Ok(config) => {
850 tracing::info!("Loaded layered config for {}", working_dir.display());
851 config
852 }
853 Err(e) => {
854 tracing::warn!("Failed to load layered config: {}, using defaults", e);
855 Self::default()
856 }
857 }
858 }
859
860 pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
868 for path in Self::config_search_paths(working_dir) {
869 if let Ok(contents) = std::fs::read_to_string(&path) {
870 match serde_json::from_str(&contents) {
871 Ok(value) => return value,
872 Err(e) => {
873 tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
874 }
875 }
876 }
877 }
878 serde_json::Value::Object(serde_json::Map::new())
879 }
880}
881
882fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
885 use serde_json::Value;
886
887 match (defaults, current) {
888 (Value::Object(def_map), Value::Object(cur_map)) => {
890 let mut result = serde_json::Map::new();
891
892 for (key, cur_val) in cur_map {
893 if let Some(def_val) = def_map.get(key) {
894 let diff = json_diff(def_val, cur_val);
896 if !is_empty_diff(&diff) {
898 result.insert(key.clone(), diff);
899 }
900 } else {
901 if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
903 result.insert(key.clone(), stripped);
904 }
905 }
906 }
907
908 Value::Object(result)
909 }
910 _ => {
912 if let Value::String(s) = current {
914 if s.is_empty() {
915 return Value::Object(serde_json::Map::new()); }
917 }
918 if defaults == current {
919 Value::Object(serde_json::Map::new()) } else {
921 current.clone()
922 }
923 }
924 }
925}
926
927fn is_empty_diff(value: &serde_json::Value) -> bool {
929 match value {
930 serde_json::Value::Object(map) => map.is_empty(),
931 _ => false,
932 }
933}
934
935#[derive(Debug, Clone)]
946pub struct DirectoryContext {
947 pub data_dir: std::path::PathBuf,
950
951 pub config_dir: std::path::PathBuf,
954
955 pub home_dir: Option<std::path::PathBuf>,
957
958 pub documents_dir: Option<std::path::PathBuf>,
960
961 pub downloads_dir: Option<std::path::PathBuf>,
963}
964
965impl DirectoryContext {
966 pub fn from_system() -> std::io::Result<Self> {
969 let data_dir = dirs::data_dir()
970 .ok_or_else(|| {
971 std::io::Error::new(
972 std::io::ErrorKind::NotFound,
973 "Could not determine data directory",
974 )
975 })?
976 .join("fresh");
977
978 let config_dir = Self::default_config_dir().ok_or_else(|| {
979 std::io::Error::new(
980 std::io::ErrorKind::NotFound,
981 "Could not determine config directory",
982 )
983 })?;
984
985 Ok(Self {
986 data_dir,
987 config_dir,
988 home_dir: dirs::home_dir(),
989 documents_dir: dirs::document_dir(),
990 downloads_dir: dirs::download_dir(),
991 })
992 }
993
994 pub fn for_testing(temp_dir: &std::path::Path) -> Self {
997 Self {
998 data_dir: temp_dir.join("data"),
999 config_dir: temp_dir.join("config"),
1000 home_dir: Some(temp_dir.join("home")),
1001 documents_dir: Some(temp_dir.join("documents")),
1002 downloads_dir: Some(temp_dir.join("downloads")),
1003 }
1004 }
1005
1006 pub fn recovery_dir(&self) -> std::path::PathBuf {
1008 self.data_dir.join("recovery")
1009 }
1010
1011 pub fn workspaces_dir(&self) -> std::path::PathBuf {
1013 self.data_dir.join("workspaces")
1014 }
1015
1016 pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
1020 let safe_name = history_name.replace(':', "_");
1022 self.data_dir.join(format!("{}_history.json", safe_name))
1023 }
1024
1025 pub fn search_history_path(&self) -> std::path::PathBuf {
1027 self.prompt_history_path("search")
1028 }
1029
1030 pub fn replace_history_path(&self) -> std::path::PathBuf {
1032 self.prompt_history_path("replace")
1033 }
1034
1035 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
1037 self.prompt_history_path("goto_line")
1038 }
1039
1040 pub fn terminals_dir(&self) -> std::path::PathBuf {
1042 self.data_dir.join("terminals")
1043 }
1044
1045 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
1047 let encoded = crate::workspace::encode_path_for_filename(working_dir);
1048 self.terminals_dir().join(encoded)
1049 }
1050
1051 pub fn config_path(&self) -> std::path::PathBuf {
1053 self.config_dir.join(Config::FILENAME)
1054 }
1055
1056 pub fn themes_dir(&self) -> std::path::PathBuf {
1058 self.config_dir.join("themes")
1059 }
1060
1061 pub fn grammars_dir(&self) -> std::path::PathBuf {
1063 self.config_dir.join("grammars")
1064 }
1065
1066 pub fn plugins_dir(&self) -> std::path::PathBuf {
1068 self.config_dir.join("plugins")
1069 }
1070
1071 fn default_config_dir() -> Option<std::path::PathBuf> {
1078 #[cfg(target_os = "macos")]
1079 {
1080 dirs::home_dir().map(|p| p.join(".config").join("fresh"))
1081 }
1082
1083 #[cfg(not(target_os = "macos"))]
1084 {
1085 dirs::config_dir().map(|p| p.join("fresh"))
1086 }
1087 }
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use tempfile::TempDir;
1094
1095 fn create_test_resolver() -> (TempDir, ConfigResolver) {
1096 let temp_dir = TempDir::new().unwrap();
1097 let dir_context = DirectoryContext::for_testing(temp_dir.path());
1098 let working_dir = temp_dir.path().join("project");
1099 std::fs::create_dir_all(&working_dir).unwrap();
1100 let resolver = ConfigResolver::new(dir_context, working_dir);
1101 (temp_dir, resolver)
1102 }
1103
1104 #[test]
1105 fn resolver_returns_defaults_when_no_config_files() {
1106 let (_temp, resolver) = create_test_resolver();
1107 let config = resolver.resolve().unwrap();
1108
1109 assert_eq!(config.editor.tab_size, 4);
1111 assert!(config.editor.line_numbers);
1112 }
1113
1114 #[test]
1115 fn resolver_loads_user_layer() {
1116 let (temp, resolver) = create_test_resolver();
1117
1118 let user_config_path = resolver.user_config_path();
1120 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1121 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1122
1123 let config = resolver.resolve().unwrap();
1124 assert_eq!(config.editor.tab_size, 2);
1125 assert!(config.editor.line_numbers); drop(temp);
1127 }
1128
1129 #[test]
1130 fn resolver_project_overrides_user() {
1131 let (temp, resolver) = create_test_resolver();
1132
1133 let user_config_path = resolver.user_config_path();
1135 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1136 std::fs::write(
1137 &user_config_path,
1138 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1139 )
1140 .unwrap();
1141
1142 let project_config_path = resolver.project_config_path();
1144 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1145 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1146
1147 let config = resolver.resolve().unwrap();
1148 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
1151 }
1152
1153 #[test]
1154 fn resolver_session_overrides_all() {
1155 let (temp, resolver) = create_test_resolver();
1156
1157 let user_config_path = resolver.user_config_path();
1159 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1160 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1161
1162 let project_config_path = resolver.project_config_path();
1164 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1165 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
1166
1167 let session_config_path = resolver.session_config_path();
1169 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1170
1171 let config = resolver.resolve().unwrap();
1172 assert_eq!(config.editor.tab_size, 16); drop(temp);
1174 }
1175
1176 #[test]
1177 fn layer_precedence_ordering() {
1178 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1179 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1180 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1181 }
1182
1183 #[test]
1184 fn save_to_system_layer_fails() {
1185 let (_temp, resolver) = create_test_resolver();
1186 let config = Config::default();
1187 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1188 assert!(result.is_err());
1189 }
1190
1191 #[test]
1192 fn resolver_loads_legacy_project_config() {
1193 let (temp, resolver) = create_test_resolver();
1194
1195 let working_dir = temp.path().join("project");
1197 let legacy_path = working_dir.join("config.json");
1198 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1199
1200 let config = resolver.resolve().unwrap();
1201 assert_eq!(config.editor.tab_size, 3);
1202 drop(temp);
1203 }
1204
1205 #[test]
1206 fn resolver_prefers_new_config_over_legacy() {
1207 let (temp, resolver) = create_test_resolver();
1208
1209 let working_dir = temp.path().join("project");
1211
1212 let legacy_path = working_dir.join("config.json");
1214 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1215
1216 let new_path = working_dir.join(".fresh").join("config.json");
1218 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1219 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1220
1221 let config = resolver.resolve().unwrap();
1222 assert_eq!(config.editor.tab_size, 5); drop(temp);
1224 }
1225
1226 #[test]
1227 fn load_with_layers_works() {
1228 let temp = TempDir::new().unwrap();
1229 let dir_context = DirectoryContext::for_testing(temp.path());
1230 let working_dir = temp.path().join("project");
1231 std::fs::create_dir_all(&working_dir).unwrap();
1232
1233 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1235 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1236
1237 let config = Config::load_with_layers(&dir_context, &working_dir);
1238 assert_eq!(config.editor.tab_size, 2);
1239 }
1240
1241 #[test]
1242 fn platform_config_overrides_user() {
1243 let (temp, resolver) = create_test_resolver();
1244
1245 let user_config_path = resolver.user_config_path();
1247 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1248 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1249
1250 if let Some(platform_path) = resolver.user_platform_config_path() {
1252 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1253
1254 let config = resolver.resolve().unwrap();
1255 assert_eq!(config.editor.tab_size, 6); }
1257 drop(temp);
1258 }
1259
1260 #[test]
1261 fn project_overrides_platform() {
1262 let (temp, resolver) = create_test_resolver();
1263
1264 let user_config_path = resolver.user_config_path();
1266 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1267 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1268
1269 if let Some(platform_path) = resolver.user_platform_config_path() {
1271 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1272 }
1273
1274 let project_config_path = resolver.project_config_path();
1276 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1277 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1278
1279 let config = resolver.resolve().unwrap();
1280 assert_eq!(config.editor.tab_size, 10); drop(temp);
1282 }
1283
1284 #[test]
1285 fn migration_adds_version() {
1286 let input = serde_json::json!({
1287 "editor": {"tab_size": 2}
1288 });
1289
1290 let migrated = migrate_config(input).unwrap();
1291
1292 assert_eq!(
1293 migrated.get("version"),
1294 Some(&serde_json::json!(CURRENT_CONFIG_VERSION))
1295 );
1296 }
1297
1298 #[test]
1299 fn migration_v1_to_v2_injects_remote_element() {
1300 let input = serde_json::json!({
1303 "version": 1,
1304 "editor": {
1305 "status_bar": {
1306 "left": ["{filename}", "{cursor}"]
1307 }
1308 }
1309 });
1310
1311 let migrated = migrate_config(input).unwrap();
1312
1313 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1314 let left = migrated
1315 .pointer("/editor/status_bar/left")
1316 .and_then(|v| v.as_array())
1317 .expect("status_bar.left should remain an array");
1318 assert_eq!(left[0], serde_json::json!("{remote}"));
1319 assert_eq!(left[1], serde_json::json!("{filename}"));
1320 assert_eq!(left[2], serde_json::json!("{cursor}"));
1321 }
1322
1323 #[test]
1324 fn migration_v1_to_v2_is_idempotent() {
1325 let input = serde_json::json!({
1328 "version": 1,
1329 "editor": {
1330 "status_bar": {
1331 "left": ["{filename}", "{remote}", "{cursor}"]
1332 }
1333 }
1334 });
1335
1336 let migrated = migrate_config(input).unwrap();
1337
1338 let left = migrated
1339 .pointer("/editor/status_bar/left")
1340 .and_then(|v| v.as_array())
1341 .unwrap();
1342 let remote_count = left
1343 .iter()
1344 .filter(|v| v.as_str() == Some("{remote}"))
1345 .count();
1346 assert_eq!(
1347 remote_count, 1,
1348 "migration should never duplicate an existing {{remote}} entry; left = {:?}",
1349 left
1350 );
1351 }
1352
1353 #[test]
1354 fn migration_v1_to_v2_leaves_default_users_alone() {
1355 let input = serde_json::json!({
1359 "version": 1,
1360 "editor": {"tab_size": 4}
1361 });
1362
1363 let migrated = migrate_config(input).unwrap();
1364
1365 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1366 assert!(
1367 migrated.pointer("/editor/status_bar").is_none(),
1368 "migration must not fabricate a status_bar object for users \
1369 who never overrode the default; migrated = {:?}",
1370 migrated
1371 );
1372 }
1373
1374 #[test]
1375 fn migration_renames_camelcase_keys() {
1376 let input = serde_json::json!({
1377 "editor": {
1378 "tabSize": 8,
1379 "lineNumbers": false
1380 }
1381 });
1382
1383 let migrated = migrate_config(input).unwrap();
1384
1385 let editor = migrated.get("editor").unwrap();
1386 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1387 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1388 assert!(editor.get("tabSize").is_none());
1389 assert!(editor.get("lineNumbers").is_none());
1390 }
1391
1392 #[test]
1393 fn migration_preserves_existing_snake_case() {
1394 let input = serde_json::json!({
1395 "version": 1,
1396 "editor": {"tab_size": 4}
1397 });
1398
1399 let migrated = migrate_config(input).unwrap();
1400
1401 let editor = migrated.get("editor").unwrap();
1402 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1403 }
1404
1405 #[test]
1406 fn resolver_loads_legacy_camelcase_config() {
1407 let (temp, resolver) = create_test_resolver();
1408
1409 let user_config_path = resolver.user_config_path();
1411 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1412 std::fs::write(
1413 &user_config_path,
1414 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1415 )
1416 .unwrap();
1417
1418 let config = resolver.resolve().unwrap();
1419 assert_eq!(config.editor.tab_size, 3);
1420 assert!(!config.editor.line_numbers);
1421 drop(temp);
1422 }
1423
1424 #[test]
1425 fn resolver_migrates_v1_status_bar_left_on_load() {
1426 let (temp, resolver) = create_test_resolver();
1431
1432 let user_config_path = resolver.user_config_path();
1433 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1434 std::fs::write(
1435 &user_config_path,
1436 r#"{
1437 "version": 1,
1438 "editor": {
1439 "status_bar": {
1440 "left": ["{filename}", "{cursor}"],
1441 "right": []
1442 }
1443 }
1444 }"#,
1445 )
1446 .unwrap();
1447
1448 let config = resolver.resolve().unwrap();
1449 let left = &config.editor.status_bar.left;
1450 assert_eq!(
1451 left.first().cloned(),
1452 Some(crate::config::StatusBarElement::RemoteIndicator),
1453 "resolver should inject RemoteIndicator at index 0 during v1→v2 \
1454 migration; left = {:?}",
1455 left
1456 );
1457 drop(temp);
1458 }
1459
1460 #[test]
1461 fn save_and_load_session() {
1462 let (_temp, resolver) = create_test_resolver();
1463
1464 let mut session = SessionConfig::new();
1465 session.set_theme(crate::config::ThemeName::from("dark"));
1466 session.set_editor_option(|e| e.tab_size = Some(2));
1467
1468 resolver.save_session(&session).unwrap();
1470
1471 let loaded = resolver.load_session().unwrap();
1473 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1474 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1475 }
1476
1477 #[test]
1478 fn clear_session_removes_file() {
1479 let (_temp, resolver) = create_test_resolver();
1480
1481 let mut session = SessionConfig::new();
1482 session.set_theme(crate::config::ThemeName::from("dark"));
1483
1484 resolver.save_session(&session).unwrap();
1486 assert!(resolver.session_config_path().exists());
1487
1488 resolver.clear_session().unwrap();
1489 assert!(!resolver.session_config_path().exists());
1490 }
1491
1492 #[test]
1493 fn load_session_returns_empty_when_no_file() {
1494 let (_temp, resolver) = create_test_resolver();
1495
1496 let session = resolver.load_session().unwrap();
1497 assert!(session.is_empty());
1498 }
1499
1500 #[test]
1501 fn session_affects_resolved_config() {
1502 let (_temp, resolver) = create_test_resolver();
1503
1504 let mut session = SessionConfig::new();
1506 session.set_editor_option(|e| e.tab_size = Some(16));
1507 resolver.save_session(&session).unwrap();
1508
1509 let config = resolver.resolve().unwrap();
1511 assert_eq!(config.editor.tab_size, 16);
1512 }
1513
1514 #[test]
1515 fn save_to_layer_writes_minimal_delta() {
1516 let (temp, resolver) = create_test_resolver();
1517
1518 let user_config_path = resolver.user_config_path();
1520 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1521 std::fs::write(
1522 &user_config_path,
1523 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1524 )
1525 .unwrap();
1526
1527 let mut config = resolver.resolve().unwrap();
1529 assert_eq!(config.editor.tab_size, 2);
1530 assert!(!config.editor.line_numbers);
1531
1532 config.editor.tab_size = 8;
1534
1535 resolver
1537 .save_to_layer(&config, ConfigLayer::Project)
1538 .unwrap();
1539
1540 let project_config_path = resolver.project_config_write_path();
1542 let content = std::fs::read_to_string(&project_config_path).unwrap();
1543 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1544
1545 assert_eq!(
1547 json.get("editor").and_then(|e| e.get("tab_size")),
1548 Some(&serde_json::json!(8)),
1549 "Project config should contain tab_size override"
1550 );
1551
1552 assert!(
1554 json.get("editor")
1555 .and_then(|e| e.get("line_numbers"))
1556 .is_none(),
1557 "Project config should NOT contain line_numbers (it's inherited from user layer)"
1558 );
1559
1560 assert!(
1562 json.get("editor")
1563 .and_then(|e| e.get("scroll_offset"))
1564 .is_none(),
1565 "Project config should NOT contain scroll_offset (it's a system default)"
1566 );
1567
1568 drop(temp);
1569 }
1570
1571 #[test]
1577 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1578 fn save_to_layer_removes_inherited_values() {
1579 let (temp, resolver) = create_test_resolver();
1580
1581 let user_config_path = resolver.user_config_path();
1583 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1584 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1585
1586 let project_config_path = resolver.project_config_write_path();
1588 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1589 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1590
1591 let mut config = resolver.resolve().unwrap();
1593 assert_eq!(config.editor.tab_size, 8);
1594
1595 config.editor.tab_size = 2;
1597
1598 resolver
1600 .save_to_layer(&config, ConfigLayer::Project)
1601 .unwrap();
1602
1603 let content = std::fs::read_to_string(&project_config_path).unwrap();
1605 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1606
1607 assert!(
1609 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1610 "Project config should NOT contain tab_size when it matches user layer"
1611 );
1612
1613 drop(temp);
1614 }
1615
1616 #[test]
1624 fn issue_630_save_to_file_strips_settings_matching_defaults() {
1625 let (_temp, resolver) = create_test_resolver();
1626
1627 let user_config_path = resolver.user_config_path();
1629 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1630 std::fs::write(
1631 &user_config_path,
1632 r#"{
1633 "theme": "dracula",
1634 "editor": {
1635 "tab_size": 2
1636 }
1637 }"#,
1638 )
1639 .unwrap();
1640
1641 let mut config = resolver.resolve().unwrap();
1643 assert_eq!(config.theme.0, "dracula");
1644 assert_eq!(config.editor.tab_size, 2);
1645
1646 if let Some(lsp_configs) = config.lsp.get_mut("python") {
1648 for c in lsp_configs.as_mut_slice().iter_mut() {
1649 c.enabled = false;
1650 }
1651 }
1652
1653 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1655
1656 let content = std::fs::read_to_string(&user_config_path).unwrap();
1658 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1659
1660 eprintln!(
1661 "Saved config:\n{}",
1662 serde_json::to_string_pretty(&json).unwrap()
1663 );
1664
1665 assert_eq!(
1667 json.get("theme").and_then(|v| v.as_str()),
1668 Some("dracula"),
1669 "Theme should be saved (differs from default)"
1670 );
1671 assert_eq!(
1672 json.get("editor")
1673 .and_then(|e| e.get("tab_size"))
1674 .and_then(|v| v.as_u64()),
1675 Some(2),
1676 "tab_size should be saved (differs from default)"
1677 );
1678 assert_eq!(
1679 json.get("lsp")
1680 .and_then(|l| l.get("python"))
1681 .and_then(|p| p.get("enabled"))
1682 .and_then(|v| v.as_bool()),
1683 Some(false),
1684 "lsp.python.enabled should be saved (differs from default)"
1685 );
1686
1687 let reloaded = resolver.resolve().unwrap();
1689 assert_eq!(reloaded.theme.0, "dracula");
1690 assert_eq!(reloaded.editor.tab_size, 2);
1691 assert!(!reloaded.lsp["python"].as_slice()[0].enabled);
1692 assert_eq!(reloaded.lsp["python"].as_slice()[0].command, "pylsp");
1694 }
1695
1696 #[test]
1703 fn toggle_lsp_preserves_command() {
1704 let (_temp, resolver) = create_test_resolver();
1705 let user_config_path = resolver.user_config_path();
1706 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1707
1708 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1710
1711 let config = resolver.resolve().unwrap();
1713 let original_command = config.lsp["python"].as_slice()[0].command.clone();
1714 assert!(
1715 !original_command.is_empty(),
1716 "Default python LSP should have a command"
1717 );
1718
1719 let mut config = resolver.resolve().unwrap();
1721 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = false;
1722 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1723
1724 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1726 assert!(
1727 !saved_content.contains(r#""command""#),
1728 "Saved config should not contain 'command' field. File content: {}",
1729 saved_content
1730 );
1731 assert!(
1732 !saved_content.contains(r#""args""#),
1733 "Saved config should not contain 'args' field. File content: {}",
1734 saved_content
1735 );
1736
1737 let mut config = resolver.resolve().unwrap();
1739 assert!(!config.lsp["python"].as_slice()[0].enabled);
1740 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
1741 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1742
1743 let config = resolver.resolve().unwrap();
1745 assert_eq!(
1746 config.lsp["python"].as_slice()[0].command,
1747 original_command,
1748 "Command should be preserved after toggling enabled. Got: '{}'",
1749 config.lsp["python"].as_slice()[0].command
1750 );
1751 }
1752
1753 #[test]
1764 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1765 let (_temp, resolver) = create_test_resolver();
1766
1767 let user_config_path = resolver.user_config_path();
1769 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1770 std::fs::write(
1771 &user_config_path,
1772 r#"{
1773 "lsp": {
1774 "json": { "enabled": false },
1775 "python": { "enabled": false },
1776 "toml": { "enabled": false }
1777 },
1778 "theme": "dracula"
1779 }"#,
1780 )
1781 .unwrap();
1782
1783 let result = resolver.resolve();
1785
1786 assert!(
1789 result.is_ok(),
1790 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1791 Got parse error: {:?}",
1792 result.err()
1793 );
1794
1795 let config = result.unwrap();
1797 assert_eq!(
1798 config.theme.0, "dracula",
1799 "Theme should be 'dracula' from config file"
1800 );
1801 }
1802
1803 #[test]
1805 fn loading_lsp_without_command_uses_default() {
1806 let (_temp, resolver) = create_test_resolver();
1807 let user_config_path = resolver.user_config_path();
1808 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1809
1810 std::fs::write(
1812 &user_config_path,
1813 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1814 )
1815 .unwrap();
1816
1817 let config = resolver.resolve().unwrap();
1819 assert_eq!(
1820 config.lsp["rust"].as_slice()[0].command,
1821 "rust-analyzer",
1822 "Command should come from defaults when not in file. Got: '{}'",
1823 config.lsp["rust"].as_slice()[0].command
1824 );
1825 assert!(
1826 !config.lsp["rust"].as_slice()[0].enabled,
1827 "enabled should be false from file"
1828 );
1829 }
1830
1831 #[test]
1837 fn settings_ui_toggle_lsp_preserves_command() {
1838 let (_temp, resolver) = create_test_resolver();
1839 let user_config_path = resolver.user_config_path();
1840 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1841
1842 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1844
1845 let config = resolver.resolve().unwrap();
1847 assert_eq!(
1848 config.lsp["rust"].as_slice()[0].command,
1849 "rust-analyzer",
1850 "Default rust command should be rust-analyzer"
1851 );
1852 assert!(
1853 config.lsp["rust"].as_slice()[0].enabled,
1854 "Default rust enabled should be true"
1855 );
1856
1857 let mut changes = std::collections::HashMap::new();
1860 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1861 let deletions = std::collections::HashSet::new();
1862
1863 resolver
1865 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1866 .unwrap();
1867
1868 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1870 eprintln!("After disable, file contains:\n{}", saved_content);
1871
1872 let reloaded = resolver.resolve().unwrap();
1874 assert_eq!(
1875 reloaded.lsp["rust"].as_slice()[0].command,
1876 "rust-analyzer",
1877 "Command should be preserved after save/reload (disabled). Got: '{}'",
1878 reloaded.lsp["rust"].as_slice()[0].command
1879 );
1880 assert!(
1881 !reloaded.lsp["rust"].as_slice()[0].enabled,
1882 "rust should be disabled"
1883 );
1884
1885 let mut changes = std::collections::HashMap::new();
1887 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1888 let deletions = std::collections::HashSet::new();
1889
1890 resolver
1892 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1893 .unwrap();
1894
1895 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1897 eprintln!("After re-enable, file contains:\n{}", saved_content);
1898
1899 let final_config = resolver.resolve().unwrap();
1901 assert_eq!(
1902 final_config.lsp["rust"].as_slice()[0].command,
1903 "rust-analyzer",
1904 "Command should be preserved after toggle cycle. Got: '{}'",
1905 final_config.lsp["rust"].as_slice()[0].command
1906 );
1907 assert!(
1908 final_config.lsp["rust"].as_slice()[0].enabled,
1909 "rust should be enabled"
1910 );
1911 }
1912
1913 #[test]
1924 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1925 let (_temp, resolver) = create_test_resolver();
1926 let user_config_path = resolver.user_config_path();
1927 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1928
1929 std::fs::write(
1932 &user_config_path,
1933 r#"{
1934 "lsp": {
1935 "rust-analyzer": {
1936 "enabled": true,
1937 "command": "rust-analyzer",
1938 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1939 "languages": ["rust"]
1940 }
1941 }
1942 }"#,
1943 )
1944 .unwrap();
1945
1946 let config = resolver.resolve().unwrap();
1948
1949 assert!(
1951 config.lsp.contains_key("rust-analyzer"),
1952 "Config should contain manually-added 'rust-analyzer' LSP entry"
1953 );
1954 let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
1955 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1956 assert_eq!(
1957 rust_analyzer.command, "rust-analyzer",
1958 "rust-analyzer command should be preserved"
1959 );
1960 assert_eq!(
1961 rust_analyzer.args,
1962 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1963 "rust-analyzer args should be preserved"
1964 );
1965
1966 let mut config_json = serde_json::to_value(&config).unwrap();
1969 *config_json
1970 .pointer_mut("/editor/tab_size")
1971 .expect("path should exist") = serde_json::json!(2);
1972 let modified_config: crate::config::Config =
1973 serde_json::from_value(config_json).expect("should deserialize");
1974
1975 resolver
1977 .save_to_layer(&modified_config, ConfigLayer::User)
1978 .unwrap();
1979
1980 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1982 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1983
1984 eprintln!(
1985 "Issue #806 - Saved config after changing tab_size:\n{}",
1986 serde_json::to_string_pretty(&saved_json).unwrap()
1987 );
1988
1989 assert!(
1991 saved_json.get("lsp").is_some(),
1992 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1993 File content: {}",
1994 saved_content
1995 );
1996
1997 assert!(
1998 saved_json
1999 .get("lsp")
2000 .and_then(|l| l.get("rust-analyzer"))
2001 .is_some(),
2002 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
2003 File content: {}",
2004 saved_content
2005 );
2006
2007 let saved_args = saved_json
2009 .get("lsp")
2010 .and_then(|l| l.get("rust-analyzer"))
2011 .and_then(|r| r.get("args"));
2012 assert!(
2013 saved_args.is_some(),
2014 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
2015 saved_content
2016 );
2017 assert_eq!(
2018 saved_args.unwrap(),
2019 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
2020 "BUG #806: Custom args should be preserved exactly"
2021 );
2022
2023 assert_eq!(
2025 saved_json
2026 .get("editor")
2027 .and_then(|e| e.get("tab_size"))
2028 .and_then(|v| v.as_u64()),
2029 Some(2),
2030 "tab_size should be saved"
2031 );
2032
2033 let reloaded = resolver.resolve().unwrap();
2035 assert_eq!(
2036 reloaded.editor.tab_size, 2,
2037 "tab_size change should be persisted"
2038 );
2039 assert!(
2040 reloaded.lsp.contains_key("rust-analyzer"),
2041 "BUG #806: rust-analyzer should still exist after reload"
2042 );
2043 let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
2044 assert_eq!(
2045 reloaded_ra.args,
2046 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
2047 "BUG #806: Custom args should survive save/reload cycle"
2048 );
2049 }
2050
2051 #[test]
2056 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
2057 let (_temp, resolver) = create_test_resolver();
2058 let user_config_path = resolver.user_config_path();
2059 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2060
2061 std::fs::write(
2063 &user_config_path,
2064 r#"{
2065 "theme": "dracula",
2066 "lsp": {
2067 "my-custom-lsp": {
2068 "enabled": true,
2069 "command": "/usr/local/bin/my-custom-lsp",
2070 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
2071 "languages": ["mycustomlang"]
2072 }
2073 },
2074 "languages": {
2075 "mycustomlang": {
2076 "extensions": [".mcl"],
2077 "grammar": "mycustomlang"
2078 }
2079 }
2080 }"#,
2081 )
2082 .unwrap();
2083
2084 let config = resolver.resolve().unwrap();
2086 assert!(
2087 config.lsp.contains_key("my-custom-lsp"),
2088 "Custom LSP entry should be loaded"
2089 );
2090 assert!(
2091 config.languages.contains_key("mycustomlang"),
2092 "Custom language should be loaded"
2093 );
2094
2095 let mut config_json = serde_json::to_value(&config).unwrap();
2097 *config_json
2098 .pointer_mut("/editor/line_numbers")
2099 .expect("path should exist") = serde_json::json!(false);
2100 let modified_config: crate::config::Config =
2101 serde_json::from_value(config_json).expect("should deserialize");
2102
2103 resolver
2105 .save_to_layer(&modified_config, ConfigLayer::User)
2106 .unwrap();
2107
2108 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2110 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2111
2112 eprintln!(
2113 "Saved config:\n{}",
2114 serde_json::to_string_pretty(&saved_json).unwrap()
2115 );
2116
2117 assert!(
2119 saved_json
2120 .get("lsp")
2121 .and_then(|l| l.get("my-custom-lsp"))
2122 .is_some(),
2123 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
2124 saved_content
2125 );
2126
2127 assert!(
2129 saved_json
2130 .get("languages")
2131 .and_then(|l| l.get("mycustomlang"))
2132 .is_some(),
2133 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
2134 saved_content
2135 );
2136
2137 let reloaded = resolver.resolve().unwrap();
2139 assert!(
2140 reloaded.lsp.contains_key("my-custom-lsp"),
2141 "Custom LSP should survive save/reload"
2142 );
2143 assert!(
2144 reloaded.languages.contains_key("mycustomlang"),
2145 "Custom language should survive save/reload"
2146 );
2147 assert!(
2148 !reloaded.editor.line_numbers,
2149 "line_numbers change should be applied"
2150 );
2151 }
2152
2153 #[test]
2166 fn issue_806_external_file_modification_lost_on_ui_save() {
2167 let (_temp, resolver) = create_test_resolver();
2168 let user_config_path = resolver.user_config_path();
2169 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2170
2171 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2173
2174 let config_at_startup = resolver.resolve().unwrap();
2176 assert_eq!(config_at_startup.theme.0, "monokai");
2177 assert!(
2178 !config_at_startup.lsp.contains_key("rust-analyzer"),
2179 "No custom LSP at startup"
2180 );
2181
2182 std::fs::write(
2185 &user_config_path,
2186 r#"{
2187 "theme": "monokai",
2188 "lsp": {
2189 "rust-analyzer": {
2190 "enabled": true,
2191 "command": "rust-analyzer",
2192 "args": ["--log-file", "/tmp/ra.log"]
2193 }
2194 }
2195 }"#,
2196 )
2197 .unwrap();
2198
2199 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2203 *config_json
2204 .pointer_mut("/editor/tab_size")
2205 .expect("path should exist") = serde_json::json!(2);
2206 let modified_config: crate::config::Config =
2207 serde_json::from_value(config_json).expect("should deserialize");
2208
2209 resolver
2213 .save_to_layer(&modified_config, ConfigLayer::User)
2214 .unwrap();
2215
2216 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2218 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2219
2220 eprintln!(
2221 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2222 serde_json::to_string_pretty(&saved_json).unwrap()
2223 );
2224
2225 assert!(
2231 saved_json.get("lsp").is_some(),
2232 "BUG #806: External edits to config.json were lost! \
2233 The 'lsp' section added while Fresh was running should be preserved. \
2234 Saved content: {}",
2235 saved_content
2236 );
2237
2238 assert!(
2239 saved_json
2240 .get("lsp")
2241 .and_then(|l| l.get("rust-analyzer"))
2242 .is_some(),
2243 "BUG #806: rust-analyzer config should be preserved"
2244 );
2245 }
2246
2247 #[test]
2253 fn issue_806_concurrent_modification_scenario() {
2254 let (_temp, resolver) = create_test_resolver();
2255 let user_config_path = resolver.user_config_path();
2256 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2257
2258 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2260
2261 let mut config = resolver.resolve().unwrap();
2263
2264 config.editor.tab_size = 8;
2266
2267 std::fs::write(
2269 &user_config_path,
2270 r#"{
2271 "lsp": {
2272 "custom-lsp": {
2273 "enabled": true,
2274 "command": "/usr/bin/custom-lsp"
2275 }
2276 }
2277 }"#,
2278 )
2279 .unwrap();
2280
2281 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2284
2285 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2287 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2288
2289 eprintln!(
2290 "Concurrent modification scenario result:\n{}",
2291 serde_json::to_string_pretty(&saved_json).unwrap()
2292 );
2293
2294 assert_eq!(
2296 saved_json
2297 .get("editor")
2298 .and_then(|e| e.get("tab_size"))
2299 .and_then(|v| v.as_u64()),
2300 Some(8),
2301 "Our tab_size change should be saved"
2302 );
2303
2304 let lsp_preserved = saved_json.get("lsp").is_some();
2310 if !lsp_preserved {
2311 eprintln!(
2312 "NOTE: Concurrent file modifications are lost with current implementation. \
2313 This is expected behavior but could be improved with read-modify-write pattern."
2314 );
2315 }
2316 }
2317
2318 #[test]
2328 fn save_to_layer_changing_to_default_value_should_persist() {
2329 let (_temp, resolver) = create_test_resolver();
2330 let user_config_path = resolver.user_config_path();
2331 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2332
2333 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2335
2336 let baseline = resolver.resolve().unwrap();
2338 assert_eq!(
2339 baseline.theme.0, "dracula",
2340 "Theme should be 'dracula' from file"
2341 );
2342
2343 let mut config = baseline.clone();
2345 config.theme = crate::config::ThemeName::from("high-contrast");
2346
2347 resolver
2349 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2350 .unwrap();
2351
2352 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2354 eprintln!(
2355 "Saved config after changing to default theme:\n{}",
2356 saved_content
2357 );
2358
2359 let reloaded = resolver.resolve().unwrap();
2361
2362 assert_eq!(
2364 reloaded.theme.0, "high-contrast",
2365 "Theme should be 'high-contrast' after changing to default and saving. \
2366 With save_to_layer_with_baseline, the theme field should be removed from file \
2367 so the default applies. File content: {}",
2368 saved_content
2369 );
2370 }
2371
2372 #[test]
2375 fn universal_lsp_round_trip_via_config_resolver() {
2376 let (_temp, resolver) = create_test_resolver();
2377 let user_config_path = resolver.user_config_path();
2378 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2379
2380 std::fs::write(
2382 &user_config_path,
2383 r#"{
2384 "universal_lsp": {
2385 "quicklsp": { "enabled": true, "auto_start": true }
2386 }
2387 }"#,
2388 )
2389 .unwrap();
2390
2391 let config = resolver.resolve().unwrap();
2392
2393 assert!(config.universal_lsp.contains_key("quicklsp"));
2395 let server = &config.universal_lsp["quicklsp"].as_slice()[0];
2396 assert!(server.enabled, "User override should enable quicklsp");
2397 assert!(server.auto_start, "User override should enable auto_start");
2398 assert_eq!(
2399 server.command, "quicklsp",
2400 "Command should come from defaults"
2401 );
2402 }
2403
2404 #[test]
2406 fn universal_lsp_custom_server_merges_with_defaults() {
2407 let (_temp, resolver) = create_test_resolver();
2408 let user_config_path = resolver.user_config_path();
2409 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2410
2411 std::fs::write(
2412 &user_config_path,
2413 r#"{
2414 "universal_lsp": {
2415 "my-universal-server": {
2416 "command": "my-server-bin",
2417 "enabled": true
2418 }
2419 }
2420 }"#,
2421 )
2422 .unwrap();
2423
2424 let config = resolver.resolve().unwrap();
2425
2426 assert!(
2428 config.universal_lsp.contains_key("my-universal-server"),
2429 "Custom universal server should be loaded"
2430 );
2431 assert_eq!(
2432 config.universal_lsp["my-universal-server"].as_slice()[0].command,
2433 "my-server-bin"
2434 );
2435
2436 assert!(
2438 config.universal_lsp.contains_key("quicklsp"),
2439 "Default quicklsp should be preserved when adding custom servers"
2440 );
2441 }
2442
2443 #[test]
2447 fn universal_lsp_partial_config_round_trip() {
2448 use crate::partial_config::PartialConfig;
2449
2450 let mut config = Config::default();
2451 if let Some(quicklsp) = config.universal_lsp.get_mut("quicklsp") {
2453 quicklsp.as_mut_slice()[0].enabled = true;
2454 }
2455
2456 let partial = PartialConfig::from(&config);
2458 let resolved = partial.resolve();
2459
2460 assert!(
2462 resolved.universal_lsp.contains_key("quicklsp"),
2463 "quicklsp should survive Config -> PartialConfig -> Config round trip"
2464 );
2465 assert!(
2466 resolved.universal_lsp["quicklsp"].as_slice()[0].enabled,
2467 "quicklsp enabled state should be preserved through round trip"
2468 );
2469 }
2470}