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 = 1;
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 Ok(value)
194}
195
196fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
199 if let Value::Object(ref mut map) = value {
200 map.insert("version".to_string(), Value::Number(1.into()));
202
203 if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
205 if let Some(val) = editor_map.remove("tabSize") {
207 editor_map.entry("tab_size").or_insert(val);
208 }
209 if let Some(val) = editor_map.remove("lineNumbers") {
211 editor_map.entry("line_numbers").or_insert(val);
212 }
213 }
214 }
215 Ok(value)
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum ConfigLayer {
221 System,
223 User,
225 Project,
227 Session,
229}
230
231impl ConfigLayer {
232 pub fn precedence(self) -> u8 {
234 match self {
235 Self::System => 0,
236 Self::User => 1,
237 Self::Project => 2,
238 Self::Session => 3,
239 }
240 }
241}
242
243pub struct ConfigResolver {
248 dir_context: DirectoryContext,
249 working_dir: PathBuf,
250}
251
252impl ConfigResolver {
253 pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
255 Self {
256 dir_context,
257 working_dir,
258 }
259 }
260
261 pub fn resolve(&self) -> Result<Config, ConfigError> {
268 let mut merged = self.load_session_layer()?.unwrap_or_default();
270
271 if let Some(project_partial) = self.load_project_layer()? {
273 tracing::debug!("Loaded project config layer");
274 merged.merge_from(&project_partial);
275 }
276
277 if let Some(platform_partial) = self.load_user_platform_layer()? {
279 tracing::debug!("Loaded user platform config layer");
280 merged.merge_from(&platform_partial);
281 }
282
283 if let Some(user_partial) = self.load_user_layer()? {
285 tracing::debug!("Loaded user config layer");
286 merged.merge_from(&user_partial);
287 }
288
289 Ok(merged.resolve())
291 }
292
293 pub fn user_config_path(&self) -> PathBuf {
295 self.dir_context.config_path()
296 }
297
298 pub fn project_config_path(&self) -> PathBuf {
301 let new_path = self.working_dir.join(".fresh").join("config.json");
302 if new_path.exists() {
303 return new_path;
304 }
305 let legacy_path = self.working_dir.join("config.json");
307 if legacy_path.exists() {
308 return legacy_path;
309 }
310 new_path
312 }
313
314 pub fn project_config_write_path(&self) -> PathBuf {
316 self.working_dir.join(".fresh").join("config.json")
317 }
318
319 pub fn session_config_path(&self) -> PathBuf {
321 self.working_dir.join(".fresh").join("session.json")
322 }
323
324 fn platform_config_filename() -> Option<&'static str> {
326 if cfg!(target_os = "linux") {
327 Some("config_linux.json")
328 } else if cfg!(target_os = "macos") {
329 Some("config_macos.json")
330 } else if cfg!(target_os = "windows") {
331 Some("config_windows.json")
332 } else {
333 None
334 }
335 }
336
337 pub fn user_platform_config_path(&self) -> Option<PathBuf> {
339 Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
340 }
341
342 pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
344 self.load_layer_from_path(&self.user_config_path())
345 }
346
347 pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
349 if let Some(path) = self.user_platform_config_path() {
350 self.load_layer_from_path(&path)
351 } else {
352 Ok(None)
353 }
354 }
355
356 pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
358 self.load_layer_from_path(&self.project_config_path())
359 }
360
361 pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
363 self.load_layer_from_path(&self.session_config_path())
364 }
365
366 fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
368 if !path.exists() {
369 return Ok(None);
370 }
371
372 let content = std::fs::read_to_string(path)
373 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
374
375 let value: Value = serde_json::from_str(&content)
377 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
378
379 let migrated = migrate_config(value)?;
381
382 let partial: PartialConfig = serde_json::from_value(migrated)
384 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
385
386 Ok(Some(partial))
387 }
388
389 pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
391 if layer == ConfigLayer::System {
392 return Err(ConfigError::ValidationError(
393 "Cannot write to System layer".to_string(),
394 ));
395 }
396
397 let parent_partial = self.resolve_up_to_layer(layer)?;
399
400 let parent = PartialConfig::from(&parent_partial.resolve());
404
405 let current = PartialConfig::from(config);
407
408 let delta = diff_partial_config(¤t, &parent);
410
411 let path = match layer {
413 ConfigLayer::User => self.user_config_path(),
414 ConfigLayer::Project => self.project_config_write_path(),
415 ConfigLayer::Session => self.session_config_path(),
416 ConfigLayer::System => unreachable!(),
417 };
418
419 if let Some(parent_dir) = path.parent() {
421 std::fs::create_dir_all(parent_dir)
422 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
423 }
424
425 let existing: PartialConfig = if path.exists() {
428 let content = std::fs::read_to_string(&path)
429 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
430 serde_json::from_str(&content).unwrap_or_default()
431 } else {
432 PartialConfig::default()
433 };
434
435 let mut merged = delta;
437 merged.merge_from(&existing);
438
439 let merged_value = serde_json::to_value(&merged)
441 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
442 let stripped_nulls = strip_nulls(merged_value).unwrap_or(Value::Object(Default::default()));
443 let clean_merged =
444 strip_empty_defaults(stripped_nulls).unwrap_or(Value::Object(Default::default()));
445
446 let json = serde_json::to_string_pretty(&clean_merged)
447 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
448 std::fs::write(&path, json)
449 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
450
451 Ok(())
452 }
453
454 pub fn save_to_layer_with_baseline(
464 &self,
465 current: &Config,
466 baseline: &Config,
467 layer: ConfigLayer,
468 ) -> Result<(), ConfigError> {
469 if layer == ConfigLayer::System {
470 return Err(ConfigError::ValidationError(
471 "Cannot write to System layer".to_string(),
472 ));
473 }
474
475 let parent_partial = self.resolve_up_to_layer(layer)?;
477 let parent = PartialConfig::from(&parent_partial.resolve());
478
479 let current_json = serde_json::to_value(current)
481 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
482 let baseline_json = serde_json::to_value(baseline)
483 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
484 let parent_json = serde_json::to_value(&parent)
485 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
486
487 let changed_paths = find_changed_paths(&baseline_json, ¤t_json);
489
490 let path = match layer {
492 ConfigLayer::User => self.user_config_path(),
493 ConfigLayer::Project => self.project_config_write_path(),
494 ConfigLayer::Session => self.session_config_path(),
495 ConfigLayer::System => unreachable!(),
496 };
497
498 if let Some(parent_dir) = path.parent() {
500 std::fs::create_dir_all(parent_dir)
501 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
502 }
503
504 let mut result: Value = if path.exists() {
506 let content = std::fs::read_to_string(&path)
507 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
508 serde_json::from_str(&content).unwrap_or(Value::Object(Default::default()))
509 } else {
510 Value::Object(Default::default())
511 };
512
513 for pointer in &changed_paths {
517 let current_val = current_json.pointer(pointer);
518 let parent_val = parent_json.pointer(pointer);
519
520 if current_val == parent_val {
521 remove_json_pointer(&mut result, pointer);
523 } else if let Some(val) = current_val {
524 set_json_pointer(&mut result, pointer, val.clone());
526 }
527 }
528
529 let stripped = strip_nulls(result).unwrap_or(Value::Object(Default::default()));
531 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
532
533 let json = serde_json::to_string_pretty(&clean)
534 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
535 std::fs::write(&path, json)
536 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
537
538 Ok(())
539 }
540
541 pub fn save_changes_to_layer(
546 &self,
547 changes: &std::collections::HashMap<String, serde_json::Value>,
548 deletions: &std::collections::HashSet<String>,
549 layer: ConfigLayer,
550 ) -> Result<(), ConfigError> {
551 if layer == ConfigLayer::System {
552 return Err(ConfigError::ValidationError(
553 "Cannot write to System layer".to_string(),
554 ));
555 }
556
557 let path = match layer {
559 ConfigLayer::User => self.user_config_path(),
560 ConfigLayer::Project => self.project_config_write_path(),
561 ConfigLayer::Session => self.session_config_path(),
562 ConfigLayer::System => unreachable!(),
563 };
564
565 if let Some(parent_dir) = path.parent() {
567 std::fs::create_dir_all(parent_dir)
568 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
569 }
570
571 let mut config_value: Value = if path.exists() {
573 let content = std::fs::read_to_string(&path)
574 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
575 serde_json::from_str(&content).unwrap_or(Value::Object(Default::default()))
576 } else {
577 Value::Object(Default::default())
578 };
579
580 for pointer in deletions {
582 remove_json_pointer(&mut config_value, pointer);
583 }
584
585 for (pointer, value) in changes {
587 set_json_pointer(&mut config_value, pointer, value.clone());
588 }
589
590 let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
592 ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
593 })?;
594
595 let stripped = strip_nulls(config_value).unwrap_or(Value::Object(Default::default()));
597 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
598
599 let json = serde_json::to_string_pretty(&clean)
600 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
601 std::fs::write(&path, json)
602 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
603
604 Ok(())
605 }
606
607 pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
609 let path = self.session_config_path();
610
611 if let Some(parent_dir) = path.parent() {
613 std::fs::create_dir_all(parent_dir)
614 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
615 }
616
617 let json = serde_json::to_string_pretty(session)
618 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
619 std::fs::write(&path, json)
620 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
621
622 tracing::debug!("Saved session config to {}", path.display());
623 Ok(())
624 }
625
626 pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
628 match self.load_session_layer()? {
629 Some(partial) => Ok(SessionConfig::from(partial)),
630 None => Ok(SessionConfig::new()),
631 }
632 }
633
634 pub fn clear_session(&self) -> Result<(), ConfigError> {
636 let path = self.session_config_path();
637 if path.exists() {
638 std::fs::remove_file(&path)
639 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
640 tracing::debug!("Cleared session config at {}", path.display());
641 }
642 Ok(())
643 }
644
645 fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
648 let mut merged = PartialConfig::default();
649
650 if layer == ConfigLayer::Session {
656 if let Some(project) = self.load_project_layer()? {
658 merged = project;
659 }
660 if let Some(platform) = self.load_user_platform_layer()? {
661 merged.merge_from(&platform);
662 }
663 if let Some(user) = self.load_user_layer()? {
664 merged.merge_from(&user);
665 }
666 } else if layer == ConfigLayer::Project {
667 if let Some(platform) = self.load_user_platform_layer()? {
669 merged = platform;
670 }
671 if let Some(user) = self.load_user_layer()? {
672 merged.merge_from(&user);
673 }
674 }
675 Ok(merged)
678 }
679
680 pub fn get_layer_sources(
683 &self,
684 ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
685 use std::collections::HashMap;
686
687 let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
688
689 if let Some(session) = self.load_session_layer()? {
694 let json = serde_json::to_value(&session).unwrap_or_default();
695 collect_paths(&json, "", &mut |path| {
696 sources.insert(path, ConfigLayer::Session);
697 });
698 }
699
700 if let Some(project) = self.load_project_layer()? {
701 let json = serde_json::to_value(&project).unwrap_or_default();
702 collect_paths(&json, "", &mut |path| {
703 sources.entry(path).or_insert(ConfigLayer::Project);
704 });
705 }
706
707 if let Some(user) = self.load_user_layer()? {
708 let json = serde_json::to_value(&user).unwrap_or_default();
709 collect_paths(&json, "", &mut |path| {
710 sources.entry(path).or_insert(ConfigLayer::User);
711 });
712 }
713
714 Ok(sources)
717 }
718}
719
720fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
722where
723 F: FnMut(String),
724{
725 match value {
726 Value::Object(map) => {
727 for (key, val) in map {
728 let path = if prefix.is_empty() {
729 format!("/{}", key)
730 } else {
731 format!("{}/{}", prefix, key)
732 };
733 collect_paths(val, &path, collector);
734 }
735 }
736 Value::Null => {} _ => {
738 collector(prefix.to_string());
740 }
741 }
742}
743
744fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
747 let current_json = serde_json::to_value(current).unwrap_or_default();
749 let parent_json = serde_json::to_value(parent).unwrap_or_default();
750
751 let diff = json_diff(&parent_json, ¤t_json);
752
753 serde_json::from_value(diff).unwrap_or_default()
755}
756
757impl Config {
758 fn system_config_paths() -> Vec<PathBuf> {
763 let mut paths = Vec::with_capacity(2);
764
765 #[cfg(target_os = "macos")]
767 if let Some(home) = dirs::home_dir() {
768 let path = home.join(".config").join("fresh").join(Config::FILENAME);
769 if path.exists() {
770 paths.push(path);
771 }
772 }
773
774 if let Some(config_dir) = dirs::config_dir() {
776 let path = config_dir.join("fresh").join(Config::FILENAME);
777 if !paths.contains(&path) && path.exists() {
778 paths.push(path);
779 }
780 }
781
782 paths
783 }
784
785 fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
793 let local = Self::local_config_path(working_dir);
794 let mut paths = Vec::with_capacity(3);
795
796 if local.exists() {
797 paths.push(local);
798 }
799
800 paths.extend(Self::system_config_paths());
801 paths
802 }
803
804 pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
808 Self::config_search_paths(working_dir).into_iter().next()
809 }
810
811 pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
816 let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
817 match resolver.resolve() {
818 Ok(config) => {
819 tracing::info!("Loaded layered config for {}", working_dir.display());
820 config
821 }
822 Err(e) => {
823 tracing::warn!("Failed to load layered config: {}, using defaults", e);
824 Self::default()
825 }
826 }
827 }
828
829 pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
837 for path in Self::config_search_paths(working_dir) {
838 if let Ok(contents) = std::fs::read_to_string(&path) {
839 match serde_json::from_str(&contents) {
840 Ok(value) => return value,
841 Err(e) => {
842 tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
843 }
844 }
845 }
846 }
847 serde_json::Value::Object(serde_json::Map::new())
848 }
849}
850
851fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
854 use serde_json::Value;
855
856 match (defaults, current) {
857 (Value::Object(def_map), Value::Object(cur_map)) => {
859 let mut result = serde_json::Map::new();
860
861 for (key, cur_val) in cur_map {
862 if let Some(def_val) = def_map.get(key) {
863 let diff = json_diff(def_val, cur_val);
865 if !is_empty_diff(&diff) {
867 result.insert(key.clone(), diff);
868 }
869 } else {
870 if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
872 result.insert(key.clone(), stripped);
873 }
874 }
875 }
876
877 Value::Object(result)
878 }
879 _ => {
881 if let Value::String(s) = current {
883 if s.is_empty() {
884 return Value::Object(serde_json::Map::new()); }
886 }
887 if defaults == current {
888 Value::Object(serde_json::Map::new()) } else {
890 current.clone()
891 }
892 }
893 }
894}
895
896fn is_empty_diff(value: &serde_json::Value) -> bool {
898 match value {
899 serde_json::Value::Object(map) => map.is_empty(),
900 _ => false,
901 }
902}
903
904#[derive(Debug, Clone)]
915pub struct DirectoryContext {
916 pub data_dir: std::path::PathBuf,
919
920 pub config_dir: std::path::PathBuf,
923
924 pub home_dir: Option<std::path::PathBuf>,
926
927 pub documents_dir: Option<std::path::PathBuf>,
929
930 pub downloads_dir: Option<std::path::PathBuf>,
932}
933
934impl DirectoryContext {
935 pub fn from_system() -> std::io::Result<Self> {
938 let data_dir = dirs::data_dir()
939 .ok_or_else(|| {
940 std::io::Error::new(
941 std::io::ErrorKind::NotFound,
942 "Could not determine data directory",
943 )
944 })?
945 .join("fresh");
946
947 let config_dir = Self::default_config_dir().ok_or_else(|| {
948 std::io::Error::new(
949 std::io::ErrorKind::NotFound,
950 "Could not determine config directory",
951 )
952 })?;
953
954 Ok(Self {
955 data_dir,
956 config_dir,
957 home_dir: dirs::home_dir(),
958 documents_dir: dirs::document_dir(),
959 downloads_dir: dirs::download_dir(),
960 })
961 }
962
963 pub fn for_testing(temp_dir: &std::path::Path) -> Self {
966 Self {
967 data_dir: temp_dir.join("data"),
968 config_dir: temp_dir.join("config"),
969 home_dir: Some(temp_dir.join("home")),
970 documents_dir: Some(temp_dir.join("documents")),
971 downloads_dir: Some(temp_dir.join("downloads")),
972 }
973 }
974
975 pub fn recovery_dir(&self) -> std::path::PathBuf {
977 self.data_dir.join("recovery")
978 }
979
980 pub fn workspaces_dir(&self) -> std::path::PathBuf {
982 self.data_dir.join("workspaces")
983 }
984
985 pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
989 let safe_name = history_name.replace(':', "_");
991 self.data_dir.join(format!("{}_history.json", safe_name))
992 }
993
994 pub fn search_history_path(&self) -> std::path::PathBuf {
996 self.prompt_history_path("search")
997 }
998
999 pub fn replace_history_path(&self) -> std::path::PathBuf {
1001 self.prompt_history_path("replace")
1002 }
1003
1004 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
1006 self.prompt_history_path("goto_line")
1007 }
1008
1009 pub fn terminals_dir(&self) -> std::path::PathBuf {
1011 self.data_dir.join("terminals")
1012 }
1013
1014 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
1016 let encoded = crate::workspace::encode_path_for_filename(working_dir);
1017 self.terminals_dir().join(encoded)
1018 }
1019
1020 pub fn config_path(&self) -> std::path::PathBuf {
1022 self.config_dir.join(Config::FILENAME)
1023 }
1024
1025 pub fn themes_dir(&self) -> std::path::PathBuf {
1027 self.config_dir.join("themes")
1028 }
1029
1030 pub fn grammars_dir(&self) -> std::path::PathBuf {
1032 self.config_dir.join("grammars")
1033 }
1034
1035 pub fn plugins_dir(&self) -> std::path::PathBuf {
1037 self.config_dir.join("plugins")
1038 }
1039
1040 fn default_config_dir() -> Option<std::path::PathBuf> {
1047 #[cfg(target_os = "macos")]
1048 {
1049 dirs::home_dir().map(|p| p.join(".config").join("fresh"))
1050 }
1051
1052 #[cfg(not(target_os = "macos"))]
1053 {
1054 dirs::config_dir().map(|p| p.join("fresh"))
1055 }
1056 }
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062 use tempfile::TempDir;
1063
1064 fn create_test_resolver() -> (TempDir, ConfigResolver) {
1065 let temp_dir = TempDir::new().unwrap();
1066 let dir_context = DirectoryContext::for_testing(temp_dir.path());
1067 let working_dir = temp_dir.path().join("project");
1068 std::fs::create_dir_all(&working_dir).unwrap();
1069 let resolver = ConfigResolver::new(dir_context, working_dir);
1070 (temp_dir, resolver)
1071 }
1072
1073 #[test]
1074 fn resolver_returns_defaults_when_no_config_files() {
1075 let (_temp, resolver) = create_test_resolver();
1076 let config = resolver.resolve().unwrap();
1077
1078 assert_eq!(config.editor.tab_size, 4);
1080 assert!(config.editor.line_numbers);
1081 }
1082
1083 #[test]
1084 fn resolver_loads_user_layer() {
1085 let (temp, resolver) = create_test_resolver();
1086
1087 let user_config_path = resolver.user_config_path();
1089 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1090 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1091
1092 let config = resolver.resolve().unwrap();
1093 assert_eq!(config.editor.tab_size, 2);
1094 assert!(config.editor.line_numbers); drop(temp);
1096 }
1097
1098 #[test]
1099 fn resolver_project_overrides_user() {
1100 let (temp, resolver) = create_test_resolver();
1101
1102 let user_config_path = resolver.user_config_path();
1104 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1105 std::fs::write(
1106 &user_config_path,
1107 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1108 )
1109 .unwrap();
1110
1111 let project_config_path = resolver.project_config_path();
1113 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1114 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1115
1116 let config = resolver.resolve().unwrap();
1117 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
1120 }
1121
1122 #[test]
1123 fn resolver_session_overrides_all() {
1124 let (temp, resolver) = create_test_resolver();
1125
1126 let user_config_path = resolver.user_config_path();
1128 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1129 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1130
1131 let project_config_path = resolver.project_config_path();
1133 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1134 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
1135
1136 let session_config_path = resolver.session_config_path();
1138 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1139
1140 let config = resolver.resolve().unwrap();
1141 assert_eq!(config.editor.tab_size, 16); drop(temp);
1143 }
1144
1145 #[test]
1146 fn layer_precedence_ordering() {
1147 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1148 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1149 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1150 }
1151
1152 #[test]
1153 fn save_to_system_layer_fails() {
1154 let (_temp, resolver) = create_test_resolver();
1155 let config = Config::default();
1156 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1157 assert!(result.is_err());
1158 }
1159
1160 #[test]
1161 fn resolver_loads_legacy_project_config() {
1162 let (temp, resolver) = create_test_resolver();
1163
1164 let working_dir = temp.path().join("project");
1166 let legacy_path = working_dir.join("config.json");
1167 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1168
1169 let config = resolver.resolve().unwrap();
1170 assert_eq!(config.editor.tab_size, 3);
1171 drop(temp);
1172 }
1173
1174 #[test]
1175 fn resolver_prefers_new_config_over_legacy() {
1176 let (temp, resolver) = create_test_resolver();
1177
1178 let working_dir = temp.path().join("project");
1180
1181 let legacy_path = working_dir.join("config.json");
1183 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1184
1185 let new_path = working_dir.join(".fresh").join("config.json");
1187 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1188 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1189
1190 let config = resolver.resolve().unwrap();
1191 assert_eq!(config.editor.tab_size, 5); drop(temp);
1193 }
1194
1195 #[test]
1196 fn load_with_layers_works() {
1197 let temp = TempDir::new().unwrap();
1198 let dir_context = DirectoryContext::for_testing(temp.path());
1199 let working_dir = temp.path().join("project");
1200 std::fs::create_dir_all(&working_dir).unwrap();
1201
1202 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1204 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1205
1206 let config = Config::load_with_layers(&dir_context, &working_dir);
1207 assert_eq!(config.editor.tab_size, 2);
1208 }
1209
1210 #[test]
1211 fn platform_config_overrides_user() {
1212 let (temp, resolver) = create_test_resolver();
1213
1214 let user_config_path = resolver.user_config_path();
1216 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1217 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1218
1219 if let Some(platform_path) = resolver.user_platform_config_path() {
1221 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1222
1223 let config = resolver.resolve().unwrap();
1224 assert_eq!(config.editor.tab_size, 6); }
1226 drop(temp);
1227 }
1228
1229 #[test]
1230 fn project_overrides_platform() {
1231 let (temp, resolver) = create_test_resolver();
1232
1233 let user_config_path = resolver.user_config_path();
1235 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1236 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1237
1238 if let Some(platform_path) = resolver.user_platform_config_path() {
1240 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1241 }
1242
1243 let project_config_path = resolver.project_config_path();
1245 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1246 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1247
1248 let config = resolver.resolve().unwrap();
1249 assert_eq!(config.editor.tab_size, 10); drop(temp);
1251 }
1252
1253 #[test]
1254 fn migration_adds_version() {
1255 let input = serde_json::json!({
1256 "editor": {"tab_size": 2}
1257 });
1258
1259 let migrated = migrate_config(input).unwrap();
1260
1261 assert_eq!(migrated.get("version"), Some(&serde_json::json!(1)));
1262 }
1263
1264 #[test]
1265 fn migration_renames_camelcase_keys() {
1266 let input = serde_json::json!({
1267 "editor": {
1268 "tabSize": 8,
1269 "lineNumbers": false
1270 }
1271 });
1272
1273 let migrated = migrate_config(input).unwrap();
1274
1275 let editor = migrated.get("editor").unwrap();
1276 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1277 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1278 assert!(editor.get("tabSize").is_none());
1279 assert!(editor.get("lineNumbers").is_none());
1280 }
1281
1282 #[test]
1283 fn migration_preserves_existing_snake_case() {
1284 let input = serde_json::json!({
1285 "version": 1,
1286 "editor": {"tab_size": 4}
1287 });
1288
1289 let migrated = migrate_config(input).unwrap();
1290
1291 let editor = migrated.get("editor").unwrap();
1292 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1293 }
1294
1295 #[test]
1296 fn resolver_loads_legacy_camelcase_config() {
1297 let (temp, resolver) = create_test_resolver();
1298
1299 let user_config_path = resolver.user_config_path();
1301 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1302 std::fs::write(
1303 &user_config_path,
1304 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1305 )
1306 .unwrap();
1307
1308 let config = resolver.resolve().unwrap();
1309 assert_eq!(config.editor.tab_size, 3);
1310 assert!(!config.editor.line_numbers);
1311 drop(temp);
1312 }
1313
1314 #[test]
1315 fn save_and_load_session() {
1316 let (_temp, resolver) = create_test_resolver();
1317
1318 let mut session = SessionConfig::new();
1319 session.set_theme(crate::config::ThemeName::from("dark"));
1320 session.set_editor_option(|e| e.tab_size = Some(2));
1321
1322 resolver.save_session(&session).unwrap();
1324
1325 let loaded = resolver.load_session().unwrap();
1327 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1328 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1329 }
1330
1331 #[test]
1332 fn clear_session_removes_file() {
1333 let (_temp, resolver) = create_test_resolver();
1334
1335 let mut session = SessionConfig::new();
1336 session.set_theme(crate::config::ThemeName::from("dark"));
1337
1338 resolver.save_session(&session).unwrap();
1340 assert!(resolver.session_config_path().exists());
1341
1342 resolver.clear_session().unwrap();
1343 assert!(!resolver.session_config_path().exists());
1344 }
1345
1346 #[test]
1347 fn load_session_returns_empty_when_no_file() {
1348 let (_temp, resolver) = create_test_resolver();
1349
1350 let session = resolver.load_session().unwrap();
1351 assert!(session.is_empty());
1352 }
1353
1354 #[test]
1355 fn session_affects_resolved_config() {
1356 let (_temp, resolver) = create_test_resolver();
1357
1358 let mut session = SessionConfig::new();
1360 session.set_editor_option(|e| e.tab_size = Some(16));
1361 resolver.save_session(&session).unwrap();
1362
1363 let config = resolver.resolve().unwrap();
1365 assert_eq!(config.editor.tab_size, 16);
1366 }
1367
1368 #[test]
1369 fn save_to_layer_writes_minimal_delta() {
1370 let (temp, resolver) = create_test_resolver();
1371
1372 let user_config_path = resolver.user_config_path();
1374 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1375 std::fs::write(
1376 &user_config_path,
1377 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1378 )
1379 .unwrap();
1380
1381 let mut config = resolver.resolve().unwrap();
1383 assert_eq!(config.editor.tab_size, 2);
1384 assert!(!config.editor.line_numbers);
1385
1386 config.editor.tab_size = 8;
1388
1389 resolver
1391 .save_to_layer(&config, ConfigLayer::Project)
1392 .unwrap();
1393
1394 let project_config_path = resolver.project_config_write_path();
1396 let content = std::fs::read_to_string(&project_config_path).unwrap();
1397 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1398
1399 assert_eq!(
1401 json.get("editor").and_then(|e| e.get("tab_size")),
1402 Some(&serde_json::json!(8)),
1403 "Project config should contain tab_size override"
1404 );
1405
1406 assert!(
1408 json.get("editor")
1409 .and_then(|e| e.get("line_numbers"))
1410 .is_none(),
1411 "Project config should NOT contain line_numbers (it's inherited from user layer)"
1412 );
1413
1414 assert!(
1416 json.get("editor")
1417 .and_then(|e| e.get("scroll_offset"))
1418 .is_none(),
1419 "Project config should NOT contain scroll_offset (it's a system default)"
1420 );
1421
1422 drop(temp);
1423 }
1424
1425 #[test]
1431 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1432 fn save_to_layer_removes_inherited_values() {
1433 let (temp, resolver) = create_test_resolver();
1434
1435 let user_config_path = resolver.user_config_path();
1437 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1438 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1439
1440 let project_config_path = resolver.project_config_write_path();
1442 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1443 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1444
1445 let mut config = resolver.resolve().unwrap();
1447 assert_eq!(config.editor.tab_size, 8);
1448
1449 config.editor.tab_size = 2;
1451
1452 resolver
1454 .save_to_layer(&config, ConfigLayer::Project)
1455 .unwrap();
1456
1457 let content = std::fs::read_to_string(&project_config_path).unwrap();
1459 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1460
1461 assert!(
1463 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1464 "Project config should NOT contain tab_size when it matches user layer"
1465 );
1466
1467 drop(temp);
1468 }
1469
1470 #[test]
1478 fn issue_630_save_to_file_strips_settings_matching_defaults() {
1479 let (_temp, resolver) = create_test_resolver();
1480
1481 let user_config_path = resolver.user_config_path();
1483 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1484 std::fs::write(
1485 &user_config_path,
1486 r#"{
1487 "theme": "dracula",
1488 "editor": {
1489 "tab_size": 2
1490 }
1491 }"#,
1492 )
1493 .unwrap();
1494
1495 let mut config = resolver.resolve().unwrap();
1497 assert_eq!(config.theme.0, "dracula");
1498 assert_eq!(config.editor.tab_size, 2);
1499
1500 if let Some(lsp_configs) = config.lsp.get_mut("python") {
1502 for c in lsp_configs.as_mut_slice().iter_mut() {
1503 c.enabled = false;
1504 }
1505 }
1506
1507 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1509
1510 let content = std::fs::read_to_string(&user_config_path).unwrap();
1512 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1513
1514 eprintln!(
1515 "Saved config:\n{}",
1516 serde_json::to_string_pretty(&json).unwrap()
1517 );
1518
1519 assert_eq!(
1521 json.get("theme").and_then(|v| v.as_str()),
1522 Some("dracula"),
1523 "Theme should be saved (differs from default)"
1524 );
1525 assert_eq!(
1526 json.get("editor")
1527 .and_then(|e| e.get("tab_size"))
1528 .and_then(|v| v.as_u64()),
1529 Some(2),
1530 "tab_size should be saved (differs from default)"
1531 );
1532 assert_eq!(
1533 json.get("lsp")
1534 .and_then(|l| l.get("python"))
1535 .and_then(|p| p.get("enabled"))
1536 .and_then(|v| v.as_bool()),
1537 Some(false),
1538 "lsp.python.enabled should be saved (differs from default)"
1539 );
1540
1541 let reloaded = resolver.resolve().unwrap();
1543 assert_eq!(reloaded.theme.0, "dracula");
1544 assert_eq!(reloaded.editor.tab_size, 2);
1545 assert!(!reloaded.lsp["python"].as_slice()[0].enabled);
1546 assert_eq!(reloaded.lsp["python"].as_slice()[0].command, "pylsp");
1548 }
1549
1550 #[test]
1557 fn toggle_lsp_preserves_command() {
1558 let (_temp, resolver) = create_test_resolver();
1559 let user_config_path = resolver.user_config_path();
1560 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1561
1562 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1564
1565 let config = resolver.resolve().unwrap();
1567 let original_command = config.lsp["python"].as_slice()[0].command.clone();
1568 assert!(
1569 !original_command.is_empty(),
1570 "Default python LSP should have a command"
1571 );
1572
1573 let mut config = resolver.resolve().unwrap();
1575 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = false;
1576 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1577
1578 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1580 assert!(
1581 !saved_content.contains(r#""command""#),
1582 "Saved config should not contain 'command' field. File content: {}",
1583 saved_content
1584 );
1585 assert!(
1586 !saved_content.contains(r#""args""#),
1587 "Saved config should not contain 'args' field. File content: {}",
1588 saved_content
1589 );
1590
1591 let mut config = resolver.resolve().unwrap();
1593 assert!(!config.lsp["python"].as_slice()[0].enabled);
1594 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
1595 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1596
1597 let config = resolver.resolve().unwrap();
1599 assert_eq!(
1600 config.lsp["python"].as_slice()[0].command,
1601 original_command,
1602 "Command should be preserved after toggling enabled. Got: '{}'",
1603 config.lsp["python"].as_slice()[0].command
1604 );
1605 }
1606
1607 #[test]
1618 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1619 let (_temp, resolver) = create_test_resolver();
1620
1621 let user_config_path = resolver.user_config_path();
1623 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1624 std::fs::write(
1625 &user_config_path,
1626 r#"{
1627 "lsp": {
1628 "json": { "enabled": false },
1629 "python": { "enabled": false },
1630 "toml": { "enabled": false }
1631 },
1632 "theme": "dracula"
1633 }"#,
1634 )
1635 .unwrap();
1636
1637 let result = resolver.resolve();
1639
1640 assert!(
1643 result.is_ok(),
1644 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1645 Got parse error: {:?}",
1646 result.err()
1647 );
1648
1649 let config = result.unwrap();
1651 assert_eq!(
1652 config.theme.0, "dracula",
1653 "Theme should be 'dracula' from config file"
1654 );
1655 }
1656
1657 #[test]
1659 fn loading_lsp_without_command_uses_default() {
1660 let (_temp, resolver) = create_test_resolver();
1661 let user_config_path = resolver.user_config_path();
1662 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1663
1664 std::fs::write(
1666 &user_config_path,
1667 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1668 )
1669 .unwrap();
1670
1671 let config = resolver.resolve().unwrap();
1673 assert_eq!(
1674 config.lsp["rust"].as_slice()[0].command,
1675 "rust-analyzer",
1676 "Command should come from defaults when not in file. Got: '{}'",
1677 config.lsp["rust"].as_slice()[0].command
1678 );
1679 assert!(
1680 !config.lsp["rust"].as_slice()[0].enabled,
1681 "enabled should be false from file"
1682 );
1683 }
1684
1685 #[test]
1691 fn settings_ui_toggle_lsp_preserves_command() {
1692 let (_temp, resolver) = create_test_resolver();
1693 let user_config_path = resolver.user_config_path();
1694 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1695
1696 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1698
1699 let config = resolver.resolve().unwrap();
1701 assert_eq!(
1702 config.lsp["rust"].as_slice()[0].command,
1703 "rust-analyzer",
1704 "Default rust command should be rust-analyzer"
1705 );
1706 assert!(
1707 config.lsp["rust"].as_slice()[0].enabled,
1708 "Default rust enabled should be true"
1709 );
1710
1711 let mut changes = std::collections::HashMap::new();
1714 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1715 let deletions = std::collections::HashSet::new();
1716
1717 resolver
1719 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1720 .unwrap();
1721
1722 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1724 eprintln!("After disable, file contains:\n{}", saved_content);
1725
1726 let reloaded = resolver.resolve().unwrap();
1728 assert_eq!(
1729 reloaded.lsp["rust"].as_slice()[0].command,
1730 "rust-analyzer",
1731 "Command should be preserved after save/reload (disabled). Got: '{}'",
1732 reloaded.lsp["rust"].as_slice()[0].command
1733 );
1734 assert!(
1735 !reloaded.lsp["rust"].as_slice()[0].enabled,
1736 "rust should be disabled"
1737 );
1738
1739 let mut changes = std::collections::HashMap::new();
1741 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1742 let deletions = std::collections::HashSet::new();
1743
1744 resolver
1746 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1747 .unwrap();
1748
1749 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1751 eprintln!("After re-enable, file contains:\n{}", saved_content);
1752
1753 let final_config = resolver.resolve().unwrap();
1755 assert_eq!(
1756 final_config.lsp["rust"].as_slice()[0].command,
1757 "rust-analyzer",
1758 "Command should be preserved after toggle cycle. Got: '{}'",
1759 final_config.lsp["rust"].as_slice()[0].command
1760 );
1761 assert!(
1762 final_config.lsp["rust"].as_slice()[0].enabled,
1763 "rust should be enabled"
1764 );
1765 }
1766
1767 #[test]
1778 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1779 let (_temp, resolver) = create_test_resolver();
1780 let user_config_path = resolver.user_config_path();
1781 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1782
1783 std::fs::write(
1786 &user_config_path,
1787 r#"{
1788 "lsp": {
1789 "rust-analyzer": {
1790 "enabled": true,
1791 "command": "rust-analyzer",
1792 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1793 "languages": ["rust"]
1794 }
1795 }
1796 }"#,
1797 )
1798 .unwrap();
1799
1800 let config = resolver.resolve().unwrap();
1802
1803 assert!(
1805 config.lsp.contains_key("rust-analyzer"),
1806 "Config should contain manually-added 'rust-analyzer' LSP entry"
1807 );
1808 let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
1809 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1810 assert_eq!(
1811 rust_analyzer.command, "rust-analyzer",
1812 "rust-analyzer command should be preserved"
1813 );
1814 assert_eq!(
1815 rust_analyzer.args,
1816 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1817 "rust-analyzer args should be preserved"
1818 );
1819
1820 let mut config_json = serde_json::to_value(&config).unwrap();
1823 *config_json
1824 .pointer_mut("/editor/tab_size")
1825 .expect("path should exist") = serde_json::json!(2);
1826 let modified_config: crate::config::Config =
1827 serde_json::from_value(config_json).expect("should deserialize");
1828
1829 resolver
1831 .save_to_layer(&modified_config, ConfigLayer::User)
1832 .unwrap();
1833
1834 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1836 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1837
1838 eprintln!(
1839 "Issue #806 - Saved config after changing tab_size:\n{}",
1840 serde_json::to_string_pretty(&saved_json).unwrap()
1841 );
1842
1843 assert!(
1845 saved_json.get("lsp").is_some(),
1846 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1847 File content: {}",
1848 saved_content
1849 );
1850
1851 assert!(
1852 saved_json
1853 .get("lsp")
1854 .and_then(|l| l.get("rust-analyzer"))
1855 .is_some(),
1856 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1857 File content: {}",
1858 saved_content
1859 );
1860
1861 let saved_args = saved_json
1863 .get("lsp")
1864 .and_then(|l| l.get("rust-analyzer"))
1865 .and_then(|r| r.get("args"));
1866 assert!(
1867 saved_args.is_some(),
1868 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1869 saved_content
1870 );
1871 assert_eq!(
1872 saved_args.unwrap(),
1873 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1874 "BUG #806: Custom args should be preserved exactly"
1875 );
1876
1877 assert_eq!(
1879 saved_json
1880 .get("editor")
1881 .and_then(|e| e.get("tab_size"))
1882 .and_then(|v| v.as_u64()),
1883 Some(2),
1884 "tab_size should be saved"
1885 );
1886
1887 let reloaded = resolver.resolve().unwrap();
1889 assert_eq!(
1890 reloaded.editor.tab_size, 2,
1891 "tab_size change should be persisted"
1892 );
1893 assert!(
1894 reloaded.lsp.contains_key("rust-analyzer"),
1895 "BUG #806: rust-analyzer should still exist after reload"
1896 );
1897 let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
1898 assert_eq!(
1899 reloaded_ra.args,
1900 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1901 "BUG #806: Custom args should survive save/reload cycle"
1902 );
1903 }
1904
1905 #[test]
1910 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
1911 let (_temp, resolver) = create_test_resolver();
1912 let user_config_path = resolver.user_config_path();
1913 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1914
1915 std::fs::write(
1917 &user_config_path,
1918 r#"{
1919 "theme": "dracula",
1920 "lsp": {
1921 "my-custom-lsp": {
1922 "enabled": true,
1923 "command": "/usr/local/bin/my-custom-lsp",
1924 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
1925 "languages": ["mycustomlang"]
1926 }
1927 },
1928 "languages": {
1929 "mycustomlang": {
1930 "extensions": [".mcl"],
1931 "grammar": "mycustomlang"
1932 }
1933 }
1934 }"#,
1935 )
1936 .unwrap();
1937
1938 let config = resolver.resolve().unwrap();
1940 assert!(
1941 config.lsp.contains_key("my-custom-lsp"),
1942 "Custom LSP entry should be loaded"
1943 );
1944 assert!(
1945 config.languages.contains_key("mycustomlang"),
1946 "Custom language should be loaded"
1947 );
1948
1949 let mut config_json = serde_json::to_value(&config).unwrap();
1951 *config_json
1952 .pointer_mut("/editor/line_numbers")
1953 .expect("path should exist") = serde_json::json!(false);
1954 let modified_config: crate::config::Config =
1955 serde_json::from_value(config_json).expect("should deserialize");
1956
1957 resolver
1959 .save_to_layer(&modified_config, ConfigLayer::User)
1960 .unwrap();
1961
1962 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1964 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1965
1966 eprintln!(
1967 "Saved config:\n{}",
1968 serde_json::to_string_pretty(&saved_json).unwrap()
1969 );
1970
1971 assert!(
1973 saved_json
1974 .get("lsp")
1975 .and_then(|l| l.get("my-custom-lsp"))
1976 .is_some(),
1977 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
1978 saved_content
1979 );
1980
1981 assert!(
1983 saved_json
1984 .get("languages")
1985 .and_then(|l| l.get("mycustomlang"))
1986 .is_some(),
1987 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
1988 saved_content
1989 );
1990
1991 let reloaded = resolver.resolve().unwrap();
1993 assert!(
1994 reloaded.lsp.contains_key("my-custom-lsp"),
1995 "Custom LSP should survive save/reload"
1996 );
1997 assert!(
1998 reloaded.languages.contains_key("mycustomlang"),
1999 "Custom language should survive save/reload"
2000 );
2001 assert!(
2002 !reloaded.editor.line_numbers,
2003 "line_numbers change should be applied"
2004 );
2005 }
2006
2007 #[test]
2020 fn issue_806_external_file_modification_lost_on_ui_save() {
2021 let (_temp, resolver) = create_test_resolver();
2022 let user_config_path = resolver.user_config_path();
2023 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2024
2025 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2027
2028 let config_at_startup = resolver.resolve().unwrap();
2030 assert_eq!(config_at_startup.theme.0, "monokai");
2031 assert!(
2032 !config_at_startup.lsp.contains_key("rust-analyzer"),
2033 "No custom LSP at startup"
2034 );
2035
2036 std::fs::write(
2039 &user_config_path,
2040 r#"{
2041 "theme": "monokai",
2042 "lsp": {
2043 "rust-analyzer": {
2044 "enabled": true,
2045 "command": "rust-analyzer",
2046 "args": ["--log-file", "/tmp/ra.log"]
2047 }
2048 }
2049 }"#,
2050 )
2051 .unwrap();
2052
2053 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2057 *config_json
2058 .pointer_mut("/editor/tab_size")
2059 .expect("path should exist") = serde_json::json!(2);
2060 let modified_config: crate::config::Config =
2061 serde_json::from_value(config_json).expect("should deserialize");
2062
2063 resolver
2067 .save_to_layer(&modified_config, ConfigLayer::User)
2068 .unwrap();
2069
2070 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2072 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2073
2074 eprintln!(
2075 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2076 serde_json::to_string_pretty(&saved_json).unwrap()
2077 );
2078
2079 assert!(
2085 saved_json.get("lsp").is_some(),
2086 "BUG #806: External edits to config.json were lost! \
2087 The 'lsp' section added while Fresh was running should be preserved. \
2088 Saved content: {}",
2089 saved_content
2090 );
2091
2092 assert!(
2093 saved_json
2094 .get("lsp")
2095 .and_then(|l| l.get("rust-analyzer"))
2096 .is_some(),
2097 "BUG #806: rust-analyzer config should be preserved"
2098 );
2099 }
2100
2101 #[test]
2107 fn issue_806_concurrent_modification_scenario() {
2108 let (_temp, resolver) = create_test_resolver();
2109 let user_config_path = resolver.user_config_path();
2110 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2111
2112 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2114
2115 let mut config = resolver.resolve().unwrap();
2117
2118 config.editor.tab_size = 8;
2120
2121 std::fs::write(
2123 &user_config_path,
2124 r#"{
2125 "lsp": {
2126 "custom-lsp": {
2127 "enabled": true,
2128 "command": "/usr/bin/custom-lsp"
2129 }
2130 }
2131 }"#,
2132 )
2133 .unwrap();
2134
2135 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2138
2139 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2141 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2142
2143 eprintln!(
2144 "Concurrent modification scenario result:\n{}",
2145 serde_json::to_string_pretty(&saved_json).unwrap()
2146 );
2147
2148 assert_eq!(
2150 saved_json
2151 .get("editor")
2152 .and_then(|e| e.get("tab_size"))
2153 .and_then(|v| v.as_u64()),
2154 Some(8),
2155 "Our tab_size change should be saved"
2156 );
2157
2158 let lsp_preserved = saved_json.get("lsp").is_some();
2164 if !lsp_preserved {
2165 eprintln!(
2166 "NOTE: Concurrent file modifications are lost with current implementation. \
2167 This is expected behavior but could be improved with read-modify-write pattern."
2168 );
2169 }
2170 }
2171
2172 #[test]
2182 fn save_to_layer_changing_to_default_value_should_persist() {
2183 let (_temp, resolver) = create_test_resolver();
2184 let user_config_path = resolver.user_config_path();
2185 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2186
2187 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2189
2190 let baseline = resolver.resolve().unwrap();
2192 assert_eq!(
2193 baseline.theme.0, "dracula",
2194 "Theme should be 'dracula' from file"
2195 );
2196
2197 let mut config = baseline.clone();
2199 config.theme = crate::config::ThemeName::from("high-contrast");
2200
2201 resolver
2203 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2204 .unwrap();
2205
2206 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2208 eprintln!(
2209 "Saved config after changing to default theme:\n{}",
2210 saved_content
2211 );
2212
2213 let reloaded = resolver.resolve().unwrap();
2215
2216 assert_eq!(
2218 reloaded.theme.0, "high-contrast",
2219 "Theme should be 'high-contrast' after changing to default and saving. \
2220 With save_to_layer_with_baseline, the theme field should be removed from file \
2221 so the default applies. File content: {}",
2222 saved_content
2223 );
2224 }
2225}