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_config) = config.lsp.get_mut("python") {
1502 lsp_config.enabled = false;
1503 }
1504
1505 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1507
1508 let content = std::fs::read_to_string(&user_config_path).unwrap();
1510 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1511
1512 eprintln!(
1513 "Saved config:\n{}",
1514 serde_json::to_string_pretty(&json).unwrap()
1515 );
1516
1517 assert_eq!(
1519 json.get("theme").and_then(|v| v.as_str()),
1520 Some("dracula"),
1521 "Theme should be saved (differs from default)"
1522 );
1523 assert_eq!(
1524 json.get("editor")
1525 .and_then(|e| e.get("tab_size"))
1526 .and_then(|v| v.as_u64()),
1527 Some(2),
1528 "tab_size should be saved (differs from default)"
1529 );
1530 assert_eq!(
1531 json.get("lsp")
1532 .and_then(|l| l.get("python"))
1533 .and_then(|p| p.get("enabled"))
1534 .and_then(|v| v.as_bool()),
1535 Some(false),
1536 "lsp.python.enabled should be saved (differs from default)"
1537 );
1538
1539 let reloaded = resolver.resolve().unwrap();
1541 assert_eq!(reloaded.theme.0, "dracula");
1542 assert_eq!(reloaded.editor.tab_size, 2);
1543 assert!(!reloaded.lsp["python"].enabled);
1544 assert_eq!(reloaded.lsp["python"].command, "pylsp");
1546 }
1547
1548 #[test]
1555 fn toggle_lsp_preserves_command() {
1556 let (_temp, resolver) = create_test_resolver();
1557 let user_config_path = resolver.user_config_path();
1558 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1559
1560 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1562
1563 let config = resolver.resolve().unwrap();
1565 let original_command = config.lsp["python"].command.clone();
1566 assert!(
1567 !original_command.is_empty(),
1568 "Default python LSP should have a command"
1569 );
1570
1571 let mut config = resolver.resolve().unwrap();
1573 config.lsp.get_mut("python").unwrap().enabled = false;
1574 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1575
1576 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1578 assert!(
1579 !saved_content.contains(r#""command""#),
1580 "Saved config should not contain 'command' field. File content: {}",
1581 saved_content
1582 );
1583 assert!(
1584 !saved_content.contains(r#""args""#),
1585 "Saved config should not contain 'args' field. File content: {}",
1586 saved_content
1587 );
1588
1589 let mut config = resolver.resolve().unwrap();
1591 assert!(!config.lsp["python"].enabled);
1592 config.lsp.get_mut("python").unwrap().enabled = true;
1593 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1594
1595 let config = resolver.resolve().unwrap();
1597 assert_eq!(
1598 config.lsp["python"].command, original_command,
1599 "Command should be preserved after toggling enabled. Got: '{}'",
1600 config.lsp["python"].command
1601 );
1602 }
1603
1604 #[test]
1615 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1616 let (_temp, resolver) = create_test_resolver();
1617
1618 let user_config_path = resolver.user_config_path();
1620 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1621 std::fs::write(
1622 &user_config_path,
1623 r#"{
1624 "lsp": {
1625 "json": { "enabled": false },
1626 "python": { "enabled": false },
1627 "toml": { "enabled": false }
1628 },
1629 "theme": "dracula"
1630 }"#,
1631 )
1632 .unwrap();
1633
1634 let result = resolver.resolve();
1636
1637 assert!(
1640 result.is_ok(),
1641 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1642 Got parse error: {:?}",
1643 result.err()
1644 );
1645
1646 let config = result.unwrap();
1648 assert_eq!(
1649 config.theme.0, "dracula",
1650 "Theme should be 'dracula' from config file"
1651 );
1652 }
1653
1654 #[test]
1656 fn loading_lsp_without_command_uses_default() {
1657 let (_temp, resolver) = create_test_resolver();
1658 let user_config_path = resolver.user_config_path();
1659 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1660
1661 std::fs::write(
1663 &user_config_path,
1664 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1665 )
1666 .unwrap();
1667
1668 let config = resolver.resolve().unwrap();
1670 assert_eq!(
1671 config.lsp["rust"].command, "rust-analyzer",
1672 "Command should come from defaults when not in file. Got: '{}'",
1673 config.lsp["rust"].command
1674 );
1675 assert!(
1676 !config.lsp["rust"].enabled,
1677 "enabled should be false from file"
1678 );
1679 }
1680
1681 #[test]
1687 fn settings_ui_toggle_lsp_preserves_command() {
1688 let (_temp, resolver) = create_test_resolver();
1689 let user_config_path = resolver.user_config_path();
1690 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1691
1692 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1694
1695 let config = resolver.resolve().unwrap();
1697 assert_eq!(
1698 config.lsp["rust"].command, "rust-analyzer",
1699 "Default rust command should be rust-analyzer"
1700 );
1701 assert!(
1702 config.lsp["rust"].enabled,
1703 "Default rust enabled should be true"
1704 );
1705
1706 let mut changes = std::collections::HashMap::new();
1709 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1710 let deletions = std::collections::HashSet::new();
1711
1712 resolver
1714 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1715 .unwrap();
1716
1717 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1719 eprintln!("After disable, file contains:\n{}", saved_content);
1720
1721 let reloaded = resolver.resolve().unwrap();
1723 assert_eq!(
1724 reloaded.lsp["rust"].command, "rust-analyzer",
1725 "Command should be preserved after save/reload (disabled). Got: '{}'",
1726 reloaded.lsp["rust"].command
1727 );
1728 assert!(!reloaded.lsp["rust"].enabled, "rust should be disabled");
1729
1730 let mut changes = std::collections::HashMap::new();
1732 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1733 let deletions = std::collections::HashSet::new();
1734
1735 resolver
1737 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1738 .unwrap();
1739
1740 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1742 eprintln!("After re-enable, file contains:\n{}", saved_content);
1743
1744 let final_config = resolver.resolve().unwrap();
1746 assert_eq!(
1747 final_config.lsp["rust"].command, "rust-analyzer",
1748 "Command should be preserved after toggle cycle. Got: '{}'",
1749 final_config.lsp["rust"].command
1750 );
1751 assert!(final_config.lsp["rust"].enabled, "rust should be enabled");
1752 }
1753
1754 #[test]
1765 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1766 let (_temp, resolver) = create_test_resolver();
1767 let user_config_path = resolver.user_config_path();
1768 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1769
1770 std::fs::write(
1773 &user_config_path,
1774 r#"{
1775 "lsp": {
1776 "rust-analyzer": {
1777 "enabled": true,
1778 "command": "rust-analyzer",
1779 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1780 "languages": ["rust"]
1781 }
1782 }
1783 }"#,
1784 )
1785 .unwrap();
1786
1787 let config = resolver.resolve().unwrap();
1789
1790 assert!(
1792 config.lsp.contains_key("rust-analyzer"),
1793 "Config should contain manually-added 'rust-analyzer' LSP entry"
1794 );
1795 let rust_analyzer = &config.lsp["rust-analyzer"];
1796 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1797 assert_eq!(
1798 rust_analyzer.command, "rust-analyzer",
1799 "rust-analyzer command should be preserved"
1800 );
1801 assert_eq!(
1802 rust_analyzer.args,
1803 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1804 "rust-analyzer args should be preserved"
1805 );
1806
1807 let mut config_json = serde_json::to_value(&config).unwrap();
1810 *config_json
1811 .pointer_mut("/editor/tab_size")
1812 .expect("path should exist") = serde_json::json!(2);
1813 let modified_config: crate::config::Config =
1814 serde_json::from_value(config_json).expect("should deserialize");
1815
1816 resolver
1818 .save_to_layer(&modified_config, ConfigLayer::User)
1819 .unwrap();
1820
1821 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1823 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1824
1825 eprintln!(
1826 "Issue #806 - Saved config after changing tab_size:\n{}",
1827 serde_json::to_string_pretty(&saved_json).unwrap()
1828 );
1829
1830 assert!(
1832 saved_json.get("lsp").is_some(),
1833 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1834 File content: {}",
1835 saved_content
1836 );
1837
1838 assert!(
1839 saved_json
1840 .get("lsp")
1841 .and_then(|l| l.get("rust-analyzer"))
1842 .is_some(),
1843 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1844 File content: {}",
1845 saved_content
1846 );
1847
1848 let saved_args = saved_json
1850 .get("lsp")
1851 .and_then(|l| l.get("rust-analyzer"))
1852 .and_then(|r| r.get("args"));
1853 assert!(
1854 saved_args.is_some(),
1855 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1856 saved_content
1857 );
1858 assert_eq!(
1859 saved_args.unwrap(),
1860 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1861 "BUG #806: Custom args should be preserved exactly"
1862 );
1863
1864 assert_eq!(
1866 saved_json
1867 .get("editor")
1868 .and_then(|e| e.get("tab_size"))
1869 .and_then(|v| v.as_u64()),
1870 Some(2),
1871 "tab_size should be saved"
1872 );
1873
1874 let reloaded = resolver.resolve().unwrap();
1876 assert_eq!(
1877 reloaded.editor.tab_size, 2,
1878 "tab_size change should be persisted"
1879 );
1880 assert!(
1881 reloaded.lsp.contains_key("rust-analyzer"),
1882 "BUG #806: rust-analyzer should still exist after reload"
1883 );
1884 let reloaded_ra = &reloaded.lsp["rust-analyzer"];
1885 assert_eq!(
1886 reloaded_ra.args,
1887 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1888 "BUG #806: Custom args should survive save/reload cycle"
1889 );
1890 }
1891
1892 #[test]
1897 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
1898 let (_temp, resolver) = create_test_resolver();
1899 let user_config_path = resolver.user_config_path();
1900 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1901
1902 std::fs::write(
1904 &user_config_path,
1905 r#"{
1906 "theme": "dracula",
1907 "lsp": {
1908 "my-custom-lsp": {
1909 "enabled": true,
1910 "command": "/usr/local/bin/my-custom-lsp",
1911 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
1912 "languages": ["mycustomlang"]
1913 }
1914 },
1915 "languages": {
1916 "mycustomlang": {
1917 "extensions": [".mcl"],
1918 "grammar": "mycustomlang"
1919 }
1920 }
1921 }"#,
1922 )
1923 .unwrap();
1924
1925 let config = resolver.resolve().unwrap();
1927 assert!(
1928 config.lsp.contains_key("my-custom-lsp"),
1929 "Custom LSP entry should be loaded"
1930 );
1931 assert!(
1932 config.languages.contains_key("mycustomlang"),
1933 "Custom language should be loaded"
1934 );
1935
1936 let mut config_json = serde_json::to_value(&config).unwrap();
1938 *config_json
1939 .pointer_mut("/editor/line_numbers")
1940 .expect("path should exist") = serde_json::json!(false);
1941 let modified_config: crate::config::Config =
1942 serde_json::from_value(config_json).expect("should deserialize");
1943
1944 resolver
1946 .save_to_layer(&modified_config, ConfigLayer::User)
1947 .unwrap();
1948
1949 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1951 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1952
1953 eprintln!(
1954 "Saved config:\n{}",
1955 serde_json::to_string_pretty(&saved_json).unwrap()
1956 );
1957
1958 assert!(
1960 saved_json
1961 .get("lsp")
1962 .and_then(|l| l.get("my-custom-lsp"))
1963 .is_some(),
1964 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
1965 saved_content
1966 );
1967
1968 assert!(
1970 saved_json
1971 .get("languages")
1972 .and_then(|l| l.get("mycustomlang"))
1973 .is_some(),
1974 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
1975 saved_content
1976 );
1977
1978 let reloaded = resolver.resolve().unwrap();
1980 assert!(
1981 reloaded.lsp.contains_key("my-custom-lsp"),
1982 "Custom LSP should survive save/reload"
1983 );
1984 assert!(
1985 reloaded.languages.contains_key("mycustomlang"),
1986 "Custom language should survive save/reload"
1987 );
1988 assert!(
1989 !reloaded.editor.line_numbers,
1990 "line_numbers change should be applied"
1991 );
1992 }
1993
1994 #[test]
2007 fn issue_806_external_file_modification_lost_on_ui_save() {
2008 let (_temp, resolver) = create_test_resolver();
2009 let user_config_path = resolver.user_config_path();
2010 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2011
2012 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2014
2015 let config_at_startup = resolver.resolve().unwrap();
2017 assert_eq!(config_at_startup.theme.0, "monokai");
2018 assert!(
2019 !config_at_startup.lsp.contains_key("rust-analyzer"),
2020 "No custom LSP at startup"
2021 );
2022
2023 std::fs::write(
2026 &user_config_path,
2027 r#"{
2028 "theme": "monokai",
2029 "lsp": {
2030 "rust-analyzer": {
2031 "enabled": true,
2032 "command": "rust-analyzer",
2033 "args": ["--log-file", "/tmp/ra.log"]
2034 }
2035 }
2036 }"#,
2037 )
2038 .unwrap();
2039
2040 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2044 *config_json
2045 .pointer_mut("/editor/tab_size")
2046 .expect("path should exist") = serde_json::json!(2);
2047 let modified_config: crate::config::Config =
2048 serde_json::from_value(config_json).expect("should deserialize");
2049
2050 resolver
2054 .save_to_layer(&modified_config, ConfigLayer::User)
2055 .unwrap();
2056
2057 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2059 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2060
2061 eprintln!(
2062 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2063 serde_json::to_string_pretty(&saved_json).unwrap()
2064 );
2065
2066 assert!(
2072 saved_json.get("lsp").is_some(),
2073 "BUG #806: External edits to config.json were lost! \
2074 The 'lsp' section added while Fresh was running should be preserved. \
2075 Saved content: {}",
2076 saved_content
2077 );
2078
2079 assert!(
2080 saved_json
2081 .get("lsp")
2082 .and_then(|l| l.get("rust-analyzer"))
2083 .is_some(),
2084 "BUG #806: rust-analyzer config should be preserved"
2085 );
2086 }
2087
2088 #[test]
2094 fn issue_806_concurrent_modification_scenario() {
2095 let (_temp, resolver) = create_test_resolver();
2096 let user_config_path = resolver.user_config_path();
2097 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2098
2099 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2101
2102 let mut config = resolver.resolve().unwrap();
2104
2105 config.editor.tab_size = 8;
2107
2108 std::fs::write(
2110 &user_config_path,
2111 r#"{
2112 "lsp": {
2113 "custom-lsp": {
2114 "enabled": true,
2115 "command": "/usr/bin/custom-lsp"
2116 }
2117 }
2118 }"#,
2119 )
2120 .unwrap();
2121
2122 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2125
2126 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2128 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2129
2130 eprintln!(
2131 "Concurrent modification scenario result:\n{}",
2132 serde_json::to_string_pretty(&saved_json).unwrap()
2133 );
2134
2135 assert_eq!(
2137 saved_json
2138 .get("editor")
2139 .and_then(|e| e.get("tab_size"))
2140 .and_then(|v| v.as_u64()),
2141 Some(8),
2142 "Our tab_size change should be saved"
2143 );
2144
2145 let lsp_preserved = saved_json.get("lsp").is_some();
2151 if !lsp_preserved {
2152 eprintln!(
2153 "NOTE: Concurrent file modifications are lost with current implementation. \
2154 This is expected behavior but could be improved with read-modify-write pattern."
2155 );
2156 }
2157 }
2158
2159 #[test]
2169 fn save_to_layer_changing_to_default_value_should_persist() {
2170 let (_temp, resolver) = create_test_resolver();
2171 let user_config_path = resolver.user_config_path();
2172 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2173
2174 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2176
2177 let baseline = resolver.resolve().unwrap();
2179 assert_eq!(
2180 baseline.theme.0, "dracula",
2181 "Theme should be 'dracula' from file"
2182 );
2183
2184 let mut config = baseline.clone();
2186 config.theme = crate::config::ThemeName::from("high-contrast");
2187
2188 resolver
2190 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2191 .unwrap();
2192
2193 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2195 eprintln!(
2196 "Saved config after changing to default theme:\n{}",
2197 saved_content
2198 );
2199
2200 let reloaded = resolver.resolve().unwrap();
2202
2203 assert_eq!(
2205 reloaded.theme.0, "high-contrast",
2206 "Theme should be 'high-contrast' after changing to default and saving. \
2207 With save_to_layer_with_baseline, the theme field should be removed from file \
2208 so the default applies. File content: {}",
2209 saved_content
2210 );
2211 }
2212}