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
174fn write_clean_value_to_path(path: &Path, value: Value) -> Result<(), ConfigError> {
177 if let Some(parent_dir) = path.parent() {
178 std::fs::create_dir_all(parent_dir)
179 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
180 }
181 let stripped = strip_nulls(value).unwrap_or(Value::Object(Default::default()));
182 let clean = strip_empty_defaults(stripped).unwrap_or(Value::Object(Default::default()));
183 let json = serde_json::to_string_pretty(&clean)
184 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
185 std::fs::write(path, json)
186 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
187 Ok(())
188}
189
190fn read_existing_json(path: &Path) -> Result<Value, ConfigError> {
193 if !path.exists() {
194 return Ok(Value::Object(Default::default()));
195 }
196 let content = std::fs::read_to_string(path)
197 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
198 Ok(serde_json::from_str(&content).unwrap_or(Value::Object(Default::default())))
199}
200
201pub const CURRENT_CONFIG_VERSION: u32 = 2;
208
209pub fn migrate_config(mut value: Value) -> Result<Value, ConfigError> {
211 let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
212
213 if version < 1 {
215 value = migrate_v0_to_v1(value)?;
216 }
217 if version < 2 {
218 value = migrate_v1_to_v2(value)?;
219 }
220
221 Ok(value)
222}
223
224fn migrate_v0_to_v1(mut value: Value) -> Result<Value, ConfigError> {
227 if let Value::Object(ref mut map) = value {
228 map.insert("version".to_string(), Value::Number(1.into()));
230
231 if let Some(Value::Object(ref mut editor_map)) = map.get_mut("editor") {
233 if let Some(val) = editor_map.remove("tabSize") {
235 editor_map.entry("tab_size").or_insert(val);
236 }
237 if let Some(val) = editor_map.remove("lineNumbers") {
239 editor_map.entry("line_numbers").or_insert(val);
240 }
241 }
242 }
243 Ok(value)
244}
245
246fn migrate_v1_to_v2(mut value: Value) -> Result<Value, ConfigError> {
255 if let Value::Object(ref mut map) = value {
256 map.insert("version".to_string(), Value::Number(2.into()));
257
258 let left = map
259 .get_mut("editor")
260 .and_then(|editor| editor.as_object_mut())
261 .and_then(|editor| editor.get_mut("status_bar"))
262 .and_then(|status_bar| status_bar.as_object_mut())
263 .and_then(|status_bar| status_bar.get_mut("left"))
264 .and_then(|left| left.as_array_mut());
265
266 if let Some(left) = left {
267 let already_present = left.iter().any(|v| v.as_str() == Some("{remote}"));
268 if !already_present {
269 left.insert(0, Value::String("{remote}".to_string()));
270 }
271 }
272 }
273 Ok(value)
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum ConfigLayer {
279 System,
281 User,
283 Project,
285 Session,
287}
288
289impl ConfigLayer {
290 pub fn precedence(self) -> u8 {
292 match self {
293 Self::System => 0,
294 Self::User => 1,
295 Self::Project => 2,
296 Self::Session => 3,
297 }
298 }
299}
300
301pub struct ConfigResolver {
306 dir_context: DirectoryContext,
307 working_dir: PathBuf,
308}
309
310impl ConfigResolver {
311 pub fn new(dir_context: DirectoryContext, working_dir: PathBuf) -> Self {
313 Self {
314 dir_context,
315 working_dir,
316 }
317 }
318
319 pub fn resolve(&self) -> Result<Config, ConfigError> {
326 let mut merged = self.load_session_layer()?.unwrap_or_default();
328
329 if let Some(project_partial) = self.load_project_layer()? {
331 tracing::debug!("Loaded project config layer");
332 merged.merge_from(&project_partial);
333 }
334
335 if let Some(platform_partial) = self.load_user_platform_layer()? {
337 tracing::debug!("Loaded user platform config layer");
338 merged.merge_from(&platform_partial);
339 }
340
341 if let Some(user_partial) = self.load_user_layer()? {
343 tracing::debug!("Loaded user config layer");
344 merged.merge_from(&user_partial);
345 }
346
347 Ok(merged.resolve())
349 }
350
351 pub fn user_config_path(&self) -> PathBuf {
353 self.dir_context.config_path()
354 }
355
356 pub fn project_config_path(&self) -> PathBuf {
359 let new_path = self.working_dir.join(".fresh").join("config.json");
360 if new_path.exists() {
361 return new_path;
362 }
363 let legacy_path = self.working_dir.join("config.json");
365 if legacy_path.exists() {
366 return legacy_path;
367 }
368 new_path
370 }
371
372 pub fn project_config_write_path(&self) -> PathBuf {
374 self.working_dir.join(".fresh").join("config.json")
375 }
376
377 pub fn session_config_path(&self) -> PathBuf {
379 self.working_dir.join(".fresh").join("session.json")
380 }
381
382 fn platform_config_filename() -> Option<&'static str> {
384 if cfg!(target_os = "linux") {
385 Some("config_linux.json")
386 } else if cfg!(target_os = "macos") {
387 Some("config_macos.json")
388 } else if cfg!(target_os = "windows") {
389 Some("config_windows.json")
390 } else {
391 None
392 }
393 }
394
395 pub fn user_platform_config_path(&self) -> Option<PathBuf> {
397 Self::platform_config_filename().map(|filename| self.dir_context.config_dir.join(filename))
398 }
399
400 pub fn load_user_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
402 self.load_layer_from_path(&self.user_config_path())
403 }
404
405 pub fn load_user_platform_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
407 if let Some(path) = self.user_platform_config_path() {
408 self.load_layer_from_path(&path)
409 } else {
410 Ok(None)
411 }
412 }
413
414 pub fn load_project_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
416 self.load_layer_from_path(&self.project_config_path())
417 }
418
419 pub fn load_session_layer(&self) -> Result<Option<PartialConfig>, ConfigError> {
421 self.load_layer_from_path(&self.session_config_path())
422 }
423
424 fn load_layer_from_path(&self, path: &Path) -> Result<Option<PartialConfig>, ConfigError> {
426 if !path.exists() {
427 return Ok(None);
428 }
429
430 let content = std::fs::read_to_string(path)
431 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
432
433 let value: Value = serde_json::from_str(&content)
435 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
436
437 let migrated = migrate_config(value)?;
439
440 let partial: PartialConfig = serde_json::from_value(migrated)
442 .map_err(|e| ConfigError::ParseError(format!("{}: {}", path.display(), e)))?;
443
444 Ok(Some(partial))
445 }
446
447 fn layer_write_path(&self, layer: ConfigLayer) -> Result<PathBuf, ConfigError> {
450 match layer {
451 ConfigLayer::User => Ok(self.user_config_path()),
452 ConfigLayer::Project => Ok(self.project_config_write_path()),
453 ConfigLayer::Session => Ok(self.session_config_path()),
454 ConfigLayer::System => Err(ConfigError::ValidationError(
455 "Cannot write to System layer".to_string(),
456 )),
457 }
458 }
459
460 pub fn save_to_layer(&self, config: &Config, layer: ConfigLayer) -> Result<(), ConfigError> {
462 let path = self.layer_write_path(layer)?;
463
464 let parent_partial = self.resolve_up_to_layer(layer)?;
465 let parent = PartialConfig::from(&parent_partial.resolve());
466 let current = PartialConfig::from(config);
467 let delta = diff_partial_config(¤t, &parent);
468
469 let existing: PartialConfig = if path.exists() {
471 let content = std::fs::read_to_string(&path)
472 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
473 serde_json::from_str(&content).unwrap_or_default()
474 } else {
475 PartialConfig::default()
476 };
477 let mut merged = delta;
478 merged.merge_from(&existing);
479
480 let merged_value = serde_json::to_value(&merged)
481 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
482 write_clean_value_to_path(&path, merged_value)
483 }
484
485 pub fn save_to_layer_with_baseline(
495 &self,
496 current: &Config,
497 baseline: &Config,
498 layer: ConfigLayer,
499 ) -> Result<(), ConfigError> {
500 let path = self.layer_write_path(layer)?;
501
502 let parent_partial = self.resolve_up_to_layer(layer)?;
503 let parent = PartialConfig::from(&parent_partial.resolve());
504
505 let current_json = serde_json::to_value(current)
506 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
507 let baseline_json = serde_json::to_value(baseline)
508 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
509 let parent_json = serde_json::to_value(&parent)
510 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
511
512 let changed_paths = find_changed_paths(&baseline_json, ¤t_json);
513
514 let mut result = read_existing_json(&path)?;
515
516 for pointer in &changed_paths {
518 let current_val = current_json.pointer(pointer);
519 let parent_val = parent_json.pointer(pointer);
520 if current_val == parent_val {
521 remove_json_pointer(&mut result, pointer);
522 } else if let Some(val) = current_val {
523 set_json_pointer(&mut result, pointer, val.clone());
524 }
525 }
526
527 write_clean_value_to_path(&path, result)
528 }
529
530 pub fn save_changes_to_layer(
535 &self,
536 changes: &std::collections::HashMap<String, serde_json::Value>,
537 deletions: &std::collections::HashSet<String>,
538 layer: ConfigLayer,
539 ) -> Result<(), ConfigError> {
540 let path = self.layer_write_path(layer)?;
541
542 let mut config_value = read_existing_json(&path)?;
543
544 for pointer in deletions {
545 remove_json_pointer(&mut config_value, pointer);
546 }
547 for (pointer, value) in changes {
548 set_json_pointer(&mut config_value, pointer, value.clone());
549 }
550
551 let _: PartialConfig = serde_json::from_value(config_value.clone()).map_err(|e| {
553 ConfigError::ValidationError(format!("Result config would be invalid: {}", e))
554 })?;
555
556 write_clean_value_to_path(&path, config_value)
557 }
558
559 pub fn save_session(&self, session: &SessionConfig) -> Result<(), ConfigError> {
561 let path = self.session_config_path();
562
563 if let Some(parent_dir) = path.parent() {
565 std::fs::create_dir_all(parent_dir)
566 .map_err(|e| ConfigError::IoError(format!("{}: {}", parent_dir.display(), e)))?;
567 }
568
569 let json = serde_json::to_string_pretty(session)
570 .map_err(|e| ConfigError::SerializeError(e.to_string()))?;
571 std::fs::write(&path, json)
572 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
573
574 tracing::debug!("Saved session config to {}", path.display());
575 Ok(())
576 }
577
578 pub fn load_session(&self) -> Result<SessionConfig, ConfigError> {
580 match self.load_session_layer()? {
581 Some(partial) => Ok(SessionConfig::from(partial)),
582 None => Ok(SessionConfig::new()),
583 }
584 }
585
586 pub fn clear_session(&self) -> Result<(), ConfigError> {
588 let path = self.session_config_path();
589 if path.exists() {
590 std::fs::remove_file(&path)
591 .map_err(|e| ConfigError::IoError(format!("{}: {}", path.display(), e)))?;
592 tracing::debug!("Cleared session config at {}", path.display());
593 }
594 Ok(())
595 }
596
597 fn resolve_up_to_layer(&self, layer: ConfigLayer) -> Result<PartialConfig, ConfigError> {
600 let mut merged = PartialConfig::default();
601
602 if layer == ConfigLayer::Session {
608 if let Some(project) = self.load_project_layer()? {
610 merged = project;
611 }
612 if let Some(platform) = self.load_user_platform_layer()? {
613 merged.merge_from(&platform);
614 }
615 if let Some(user) = self.load_user_layer()? {
616 merged.merge_from(&user);
617 }
618 } else if layer == ConfigLayer::Project {
619 if let Some(platform) = self.load_user_platform_layer()? {
621 merged = platform;
622 }
623 if let Some(user) = self.load_user_layer()? {
624 merged.merge_from(&user);
625 }
626 }
627 Ok(merged)
630 }
631
632 pub fn get_layer_sources(
635 &self,
636 ) -> Result<std::collections::HashMap<String, ConfigLayer>, ConfigError> {
637 use std::collections::HashMap;
638
639 let mut sources: HashMap<String, ConfigLayer> = HashMap::new();
640
641 if let Some(session) = self.load_session_layer()? {
646 let json = serde_json::to_value(&session).unwrap_or_default();
647 collect_paths(&json, "", &mut |path| {
648 sources.insert(path, ConfigLayer::Session);
649 });
650 }
651
652 if let Some(project) = self.load_project_layer()? {
653 let json = serde_json::to_value(&project).unwrap_or_default();
654 collect_paths(&json, "", &mut |path| {
655 sources.entry(path).or_insert(ConfigLayer::Project);
656 });
657 }
658
659 if let Some(user) = self.load_user_layer()? {
660 let json = serde_json::to_value(&user).unwrap_or_default();
661 collect_paths(&json, "", &mut |path| {
662 sources.entry(path).or_insert(ConfigLayer::User);
663 });
664 }
665
666 Ok(sources)
669 }
670}
671
672fn collect_paths<F>(value: &Value, prefix: &str, collector: &mut F)
674where
675 F: FnMut(String),
676{
677 match value {
678 Value::Object(map) => {
679 for (key, val) in map {
680 let path = if prefix.is_empty() {
681 format!("/{}", key)
682 } else {
683 format!("{}/{}", prefix, key)
684 };
685 collect_paths(val, &path, collector);
686 }
687 }
688 Value::Null => {} _ => {
690 collector(prefix.to_string());
692 }
693 }
694}
695
696fn diff_partial_config(current: &PartialConfig, parent: &PartialConfig) -> PartialConfig {
699 let current_json = serde_json::to_value(current).unwrap_or_default();
701 let parent_json = serde_json::to_value(parent).unwrap_or_default();
702
703 let diff = json_diff(&parent_json, ¤t_json);
704
705 serde_json::from_value(diff).unwrap_or_default()
707}
708
709impl Config {
710 fn system_config_paths() -> Vec<PathBuf> {
715 let mut paths = Vec::with_capacity(2);
716
717 #[cfg(target_os = "macos")]
719 if let Some(home) = dirs::home_dir() {
720 let path = home.join(".config").join("fresh").join(Config::FILENAME);
721 if path.exists() {
722 paths.push(path);
723 }
724 }
725
726 if let Some(config_dir) = dirs::config_dir() {
728 let path = config_dir.join("fresh").join(Config::FILENAME);
729 if !paths.contains(&path) && path.exists() {
730 paths.push(path);
731 }
732 }
733
734 paths
735 }
736
737 fn config_search_paths(working_dir: &Path) -> Vec<PathBuf> {
745 let local = Self::local_config_path(working_dir);
746 let mut paths = Vec::with_capacity(3);
747
748 if local.exists() {
749 paths.push(local);
750 }
751
752 paths.extend(Self::system_config_paths());
753 paths
754 }
755
756 pub fn find_config_path(working_dir: &Path) -> Option<PathBuf> {
760 Self::config_search_paths(working_dir).into_iter().next()
761 }
762
763 pub fn load_with_layers(dir_context: &DirectoryContext, working_dir: &Path) -> Self {
768 let resolver = ConfigResolver::new(dir_context.clone(), working_dir.to_path_buf());
769 match resolver.resolve() {
770 Ok(config) => {
771 tracing::info!("Loaded layered config for {}", working_dir.display());
772 config
773 }
774 Err(e) => {
775 tracing::warn!("Failed to load layered config: {}, using defaults", e);
776 Self::default()
777 }
778 }
779 }
780
781 pub fn read_user_config_raw(working_dir: &Path) -> serde_json::Value {
789 for path in Self::config_search_paths(working_dir) {
790 if let Ok(contents) = std::fs::read_to_string(&path) {
791 match serde_json::from_str(&contents) {
792 Ok(value) => return value,
793 Err(e) => {
794 tracing::warn!("Failed to parse config from {}: {}", path.display(), e);
795 }
796 }
797 }
798 }
799 serde_json::Value::Object(serde_json::Map::new())
800 }
801}
802
803fn json_diff(defaults: &serde_json::Value, current: &serde_json::Value) -> serde_json::Value {
806 use serde_json::Value;
807
808 match (defaults, current) {
809 (Value::Object(def_map), Value::Object(cur_map)) => {
811 let mut result = serde_json::Map::new();
812
813 for (key, cur_val) in cur_map {
814 if let Some(def_val) = def_map.get(key) {
815 let diff = json_diff(def_val, cur_val);
817 if !is_empty_diff(&diff) {
819 result.insert(key.clone(), diff);
820 }
821 } else {
822 if let Some(stripped) = strip_empty_defaults(cur_val.clone()) {
824 result.insert(key.clone(), stripped);
825 }
826 }
827 }
828
829 Value::Object(result)
830 }
831 _ => {
833 if let Value::String(s) = current {
835 if s.is_empty() {
836 return Value::Object(serde_json::Map::new()); }
838 }
839 if defaults == current {
840 Value::Object(serde_json::Map::new()) } else {
842 current.clone()
843 }
844 }
845 }
846}
847
848fn is_empty_diff(value: &serde_json::Value) -> bool {
850 match value {
851 serde_json::Value::Object(map) => map.is_empty(),
852 _ => false,
853 }
854}
855
856#[derive(Debug, Clone)]
867pub struct DirectoryContext {
868 pub data_dir: std::path::PathBuf,
871
872 pub config_dir: std::path::PathBuf,
875
876 pub home_dir: Option<std::path::PathBuf>,
878
879 pub documents_dir: Option<std::path::PathBuf>,
881
882 pub downloads_dir: Option<std::path::PathBuf>,
884}
885
886impl DirectoryContext {
887 pub fn from_system() -> std::io::Result<Self> {
890 let data_dir = dirs::data_dir()
891 .ok_or_else(|| {
892 std::io::Error::new(
893 std::io::ErrorKind::NotFound,
894 "Could not determine data directory",
895 )
896 })?
897 .join("fresh");
898
899 let config_dir = Self::default_config_dir().ok_or_else(|| {
900 std::io::Error::new(
901 std::io::ErrorKind::NotFound,
902 "Could not determine config directory",
903 )
904 })?;
905
906 Ok(Self {
907 data_dir,
908 config_dir,
909 home_dir: dirs::home_dir(),
910 documents_dir: dirs::document_dir(),
911 downloads_dir: dirs::download_dir(),
912 })
913 }
914
915 pub fn for_testing(temp_dir: &std::path::Path) -> Self {
918 Self {
919 data_dir: temp_dir.join("data"),
920 config_dir: temp_dir.join("config"),
921 home_dir: Some(temp_dir.join("home")),
922 documents_dir: Some(temp_dir.join("documents")),
923 downloads_dir: Some(temp_dir.join("downloads")),
924 }
925 }
926
927 pub fn recovery_dir(&self) -> std::path::PathBuf {
929 self.data_dir.join("recovery")
930 }
931
932 pub fn workspaces_dir(&self) -> std::path::PathBuf {
934 self.data_dir.join("workspaces")
935 }
936
937 pub fn project_state_dir(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
953 let canonical = working_dir
954 .canonicalize()
955 .unwrap_or_else(|_| working_dir.to_path_buf());
956 self.workspaces_dir()
957 .join(crate::workspace::encode_path_for_filename(&canonical))
958 }
959
960 pub fn prompt_history_path(&self, history_name: &str) -> std::path::PathBuf {
964 let safe_name = history_name.replace(':', "_");
966 self.data_dir.join(format!("{}_history.json", safe_name))
967 }
968
969 pub fn search_history_path(&self) -> std::path::PathBuf {
971 self.prompt_history_path("search")
972 }
973
974 pub fn replace_history_path(&self) -> std::path::PathBuf {
976 self.prompt_history_path("replace")
977 }
978
979 pub fn goto_line_history_path(&self) -> std::path::PathBuf {
981 self.prompt_history_path("goto_line")
982 }
983
984 pub fn terminals_dir(&self) -> std::path::PathBuf {
986 self.data_dir.join("terminals")
987 }
988
989 pub fn terminal_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
991 let encoded = crate::workspace::encode_path_for_filename(working_dir);
992 self.terminals_dir().join(encoded)
993 }
994
995 pub fn working_data_dir_for(&self, working_dir: &std::path::Path) -> std::path::PathBuf {
1001 let encoded = crate::workspace::encode_path_for_filename(working_dir);
1002 self.data_dir.join("workdirs").join(encoded)
1003 }
1004
1005 pub fn config_path(&self) -> std::path::PathBuf {
1007 self.config_dir.join(Config::FILENAME)
1008 }
1009
1010 pub fn themes_dir(&self) -> std::path::PathBuf {
1012 self.config_dir.join("themes")
1013 }
1014
1015 pub fn grammars_dir(&self) -> std::path::PathBuf {
1017 self.config_dir.join("grammars")
1018 }
1019
1020 pub fn plugins_dir(&self) -> std::path::PathBuf {
1022 self.config_dir.join("plugins")
1023 }
1024
1025 fn default_config_dir() -> Option<std::path::PathBuf> {
1032 #[cfg(target_os = "macos")]
1033 {
1034 dirs::home_dir().map(|p| p.join(".config").join("fresh"))
1035 }
1036
1037 #[cfg(not(target_os = "macos"))]
1038 {
1039 dirs::config_dir().map(|p| p.join("fresh"))
1040 }
1041 }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use tempfile::TempDir;
1048
1049 fn create_test_resolver() -> (TempDir, ConfigResolver) {
1050 let temp_dir = TempDir::new().unwrap();
1051 let dir_context = DirectoryContext::for_testing(temp_dir.path());
1052 let working_dir = temp_dir.path().join("project");
1053 std::fs::create_dir_all(&working_dir).unwrap();
1054 let resolver = ConfigResolver::new(dir_context, working_dir);
1055 (temp_dir, resolver)
1056 }
1057
1058 #[test]
1059 fn resolver_returns_defaults_when_no_config_files() {
1060 let (_temp, resolver) = create_test_resolver();
1061 let config = resolver.resolve().unwrap();
1062
1063 assert_eq!(config.editor.tab_size, 4);
1065 assert!(config.editor.line_numbers);
1066 }
1067
1068 #[test]
1069 fn resolver_loads_user_layer() {
1070 let (temp, resolver) = create_test_resolver();
1071
1072 let user_config_path = resolver.user_config_path();
1074 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1075 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1076
1077 let config = resolver.resolve().unwrap();
1078 assert_eq!(config.editor.tab_size, 2);
1079 assert!(config.editor.line_numbers); drop(temp);
1081 }
1082
1083 #[test]
1084 fn resolver_project_overrides_user() {
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(
1091 &user_config_path,
1092 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1093 )
1094 .unwrap();
1095
1096 let project_config_path = resolver.project_config_path();
1098 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1099 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1100
1101 let config = resolver.resolve().unwrap();
1102 assert_eq!(config.editor.tab_size, 8); assert!(!config.editor.line_numbers); drop(temp);
1105 }
1106
1107 #[test]
1108 fn resolver_session_overrides_all() {
1109 let (temp, resolver) = create_test_resolver();
1110
1111 let user_config_path = resolver.user_config_path();
1113 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1114 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1115
1116 let project_config_path = resolver.project_config_path();
1118 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1119 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 4}}"#).unwrap();
1120
1121 let session_config_path = resolver.session_config_path();
1123 std::fs::write(&session_config_path, r#"{"editor": {"tab_size": 16}}"#).unwrap();
1124
1125 let config = resolver.resolve().unwrap();
1126 assert_eq!(config.editor.tab_size, 16); drop(temp);
1128 }
1129
1130 #[test]
1131 fn layer_precedence_ordering() {
1132 assert!(ConfigLayer::Session.precedence() > ConfigLayer::Project.precedence());
1133 assert!(ConfigLayer::Project.precedence() > ConfigLayer::User.precedence());
1134 assert!(ConfigLayer::User.precedence() > ConfigLayer::System.precedence());
1135 }
1136
1137 #[test]
1138 fn save_to_system_layer_fails() {
1139 let (_temp, resolver) = create_test_resolver();
1140 let config = Config::default();
1141 let result = resolver.save_to_layer(&config, ConfigLayer::System);
1142 assert!(result.is_err());
1143 }
1144
1145 #[test]
1146 fn resolver_loads_legacy_project_config() {
1147 let (temp, resolver) = create_test_resolver();
1148
1149 let working_dir = temp.path().join("project");
1151 let legacy_path = working_dir.join("config.json");
1152 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1153
1154 let config = resolver.resolve().unwrap();
1155 assert_eq!(config.editor.tab_size, 3);
1156 drop(temp);
1157 }
1158
1159 #[test]
1160 fn resolver_prefers_new_config_over_legacy() {
1161 let (temp, resolver) = create_test_resolver();
1162
1163 let working_dir = temp.path().join("project");
1165
1166 let legacy_path = working_dir.join("config.json");
1168 std::fs::write(&legacy_path, r#"{"editor": {"tab_size": 3}}"#).unwrap();
1169
1170 let new_path = working_dir.join(".fresh").join("config.json");
1172 std::fs::create_dir_all(new_path.parent().unwrap()).unwrap();
1173 std::fs::write(&new_path, r#"{"editor": {"tab_size": 5}}"#).unwrap();
1174
1175 let config = resolver.resolve().unwrap();
1176 assert_eq!(config.editor.tab_size, 5); drop(temp);
1178 }
1179
1180 #[test]
1181 fn load_with_layers_works() {
1182 let temp = TempDir::new().unwrap();
1183 let dir_context = DirectoryContext::for_testing(temp.path());
1184 let working_dir = temp.path().join("project");
1185 std::fs::create_dir_all(&working_dir).unwrap();
1186
1187 std::fs::create_dir_all(&dir_context.config_dir).unwrap();
1189 std::fs::write(dir_context.config_path(), r#"{"editor": {"tab_size": 2}}"#).unwrap();
1190
1191 let config = Config::load_with_layers(&dir_context, &working_dir);
1192 assert_eq!(config.editor.tab_size, 2);
1193 }
1194
1195 #[test]
1196 fn platform_config_overrides_user() {
1197 let (temp, resolver) = create_test_resolver();
1198
1199 let user_config_path = resolver.user_config_path();
1201 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1202 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1203
1204 if let Some(platform_path) = resolver.user_platform_config_path() {
1206 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1207
1208 let config = resolver.resolve().unwrap();
1209 assert_eq!(config.editor.tab_size, 6); }
1211 drop(temp);
1212 }
1213
1214 #[test]
1215 fn project_overrides_platform() {
1216 let (temp, resolver) = create_test_resolver();
1217
1218 let user_config_path = resolver.user_config_path();
1220 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1221 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1222
1223 if let Some(platform_path) = resolver.user_platform_config_path() {
1225 std::fs::write(&platform_path, r#"{"editor": {"tab_size": 6}}"#).unwrap();
1226 }
1227
1228 let project_config_path = resolver.project_config_path();
1230 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1231 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 10}}"#).unwrap();
1232
1233 let config = resolver.resolve().unwrap();
1234 assert_eq!(config.editor.tab_size, 10); drop(temp);
1236 }
1237
1238 #[test]
1239 fn migration_adds_version() {
1240 let input = serde_json::json!({
1241 "editor": {"tab_size": 2}
1242 });
1243
1244 let migrated = migrate_config(input).unwrap();
1245
1246 assert_eq!(
1247 migrated.get("version"),
1248 Some(&serde_json::json!(CURRENT_CONFIG_VERSION))
1249 );
1250 }
1251
1252 #[test]
1253 fn migration_v1_to_v2_injects_remote_element() {
1254 let input = serde_json::json!({
1257 "version": 1,
1258 "editor": {
1259 "status_bar": {
1260 "left": ["{filename}", "{cursor}"]
1261 }
1262 }
1263 });
1264
1265 let migrated = migrate_config(input).unwrap();
1266
1267 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1268 let left = migrated
1269 .pointer("/editor/status_bar/left")
1270 .and_then(|v| v.as_array())
1271 .expect("status_bar.left should remain an array");
1272 assert_eq!(left[0], serde_json::json!("{remote}"));
1273 assert_eq!(left[1], serde_json::json!("{filename}"));
1274 assert_eq!(left[2], serde_json::json!("{cursor}"));
1275 }
1276
1277 #[test]
1278 fn migration_v1_to_v2_is_idempotent() {
1279 let input = serde_json::json!({
1282 "version": 1,
1283 "editor": {
1284 "status_bar": {
1285 "left": ["{filename}", "{remote}", "{cursor}"]
1286 }
1287 }
1288 });
1289
1290 let migrated = migrate_config(input).unwrap();
1291
1292 let left = migrated
1293 .pointer("/editor/status_bar/left")
1294 .and_then(|v| v.as_array())
1295 .unwrap();
1296 let remote_count = left
1297 .iter()
1298 .filter(|v| v.as_str() == Some("{remote}"))
1299 .count();
1300 assert_eq!(
1301 remote_count, 1,
1302 "migration should never duplicate an existing {{remote}} entry; left = {:?}",
1303 left
1304 );
1305 }
1306
1307 #[test]
1308 fn migration_v1_to_v2_leaves_default_users_alone() {
1309 let input = serde_json::json!({
1313 "version": 1,
1314 "editor": {"tab_size": 4}
1315 });
1316
1317 let migrated = migrate_config(input).unwrap();
1318
1319 assert_eq!(migrated.get("version"), Some(&serde_json::json!(2)));
1320 assert!(
1321 migrated.pointer("/editor/status_bar").is_none(),
1322 "migration must not fabricate a status_bar object for users \
1323 who never overrode the default; migrated = {:?}",
1324 migrated
1325 );
1326 }
1327
1328 #[test]
1329 fn migration_renames_camelcase_keys() {
1330 let input = serde_json::json!({
1331 "editor": {
1332 "tabSize": 8,
1333 "lineNumbers": false
1334 }
1335 });
1336
1337 let migrated = migrate_config(input).unwrap();
1338
1339 let editor = migrated.get("editor").unwrap();
1340 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(8)));
1341 assert_eq!(editor.get("line_numbers"), Some(&serde_json::json!(false)));
1342 assert!(editor.get("tabSize").is_none());
1343 assert!(editor.get("lineNumbers").is_none());
1344 }
1345
1346 #[test]
1347 fn migration_preserves_existing_snake_case() {
1348 let input = serde_json::json!({
1349 "version": 1,
1350 "editor": {"tab_size": 4}
1351 });
1352
1353 let migrated = migrate_config(input).unwrap();
1354
1355 let editor = migrated.get("editor").unwrap();
1356 assert_eq!(editor.get("tab_size"), Some(&serde_json::json!(4)));
1357 }
1358
1359 #[test]
1360 fn resolver_loads_legacy_camelcase_config() {
1361 let (temp, resolver) = create_test_resolver();
1362
1363 let user_config_path = resolver.user_config_path();
1365 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1366 std::fs::write(
1367 &user_config_path,
1368 r#"{"editor": {"tabSize": 3, "lineNumbers": false}}"#,
1369 )
1370 .unwrap();
1371
1372 let config = resolver.resolve().unwrap();
1373 assert_eq!(config.editor.tab_size, 3);
1374 assert!(!config.editor.line_numbers);
1375 drop(temp);
1376 }
1377
1378 #[test]
1379 fn resolver_migrates_v1_status_bar_left_on_load() {
1380 let (temp, resolver) = create_test_resolver();
1385
1386 let user_config_path = resolver.user_config_path();
1387 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1388 std::fs::write(
1389 &user_config_path,
1390 r#"{
1391 "version": 1,
1392 "editor": {
1393 "status_bar": {
1394 "left": ["{filename}", "{cursor}"],
1395 "right": []
1396 }
1397 }
1398 }"#,
1399 )
1400 .unwrap();
1401
1402 let config = resolver.resolve().unwrap();
1403 let left = &config.editor.status_bar.left;
1404 assert_eq!(
1405 left.first().cloned(),
1406 Some(crate::config::StatusBarElement::RemoteIndicator),
1407 "resolver should inject RemoteIndicator at index 0 during v1→v2 \
1408 migration; left = {:?}",
1409 left
1410 );
1411 drop(temp);
1412 }
1413
1414 #[test]
1415 fn save_and_load_session() {
1416 let (_temp, resolver) = create_test_resolver();
1417
1418 let mut session = SessionConfig::new();
1419 session.set_theme(crate::config::ThemeName::from("dark"));
1420 session.set_editor_option(|e| e.tab_size = Some(2));
1421
1422 resolver.save_session(&session).unwrap();
1424
1425 let loaded = resolver.load_session().unwrap();
1427 assert_eq!(loaded.theme, Some(crate::config::ThemeName::from("dark")));
1428 assert_eq!(loaded.editor.as_ref().unwrap().tab_size, Some(2));
1429 }
1430
1431 #[test]
1432 fn clear_session_removes_file() {
1433 let (_temp, resolver) = create_test_resolver();
1434
1435 let mut session = SessionConfig::new();
1436 session.set_theme(crate::config::ThemeName::from("dark"));
1437
1438 resolver.save_session(&session).unwrap();
1440 assert!(resolver.session_config_path().exists());
1441
1442 resolver.clear_session().unwrap();
1443 assert!(!resolver.session_config_path().exists());
1444 }
1445
1446 #[test]
1447 fn load_session_returns_empty_when_no_file() {
1448 let (_temp, resolver) = create_test_resolver();
1449
1450 let session = resolver.load_session().unwrap();
1451 assert!(session.is_empty());
1452 }
1453
1454 #[test]
1455 fn session_affects_resolved_config() {
1456 let (_temp, resolver) = create_test_resolver();
1457
1458 let mut session = SessionConfig::new();
1460 session.set_editor_option(|e| e.tab_size = Some(16));
1461 resolver.save_session(&session).unwrap();
1462
1463 let config = resolver.resolve().unwrap();
1465 assert_eq!(config.editor.tab_size, 16);
1466 }
1467
1468 #[test]
1469 fn save_to_layer_writes_minimal_delta() {
1470 let (temp, resolver) = create_test_resolver();
1471
1472 let user_config_path = resolver.user_config_path();
1474 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1475 std::fs::write(
1476 &user_config_path,
1477 r#"{"editor": {"tab_size": 2, "line_numbers": false}}"#,
1478 )
1479 .unwrap();
1480
1481 let mut config = resolver.resolve().unwrap();
1483 assert_eq!(config.editor.tab_size, 2);
1484 assert!(!config.editor.line_numbers);
1485
1486 config.editor.tab_size = 8;
1488
1489 resolver
1491 .save_to_layer(&config, ConfigLayer::Project)
1492 .unwrap();
1493
1494 let project_config_path = resolver.project_config_write_path();
1496 let content = std::fs::read_to_string(&project_config_path).unwrap();
1497 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1498
1499 assert_eq!(
1501 json.get("editor").and_then(|e| e.get("tab_size")),
1502 Some(&serde_json::json!(8)),
1503 "Project config should contain tab_size override"
1504 );
1505
1506 assert!(
1508 json.get("editor")
1509 .and_then(|e| e.get("line_numbers"))
1510 .is_none(),
1511 "Project config should NOT contain line_numbers (it's inherited from user layer)"
1512 );
1513
1514 assert!(
1516 json.get("editor")
1517 .and_then(|e| e.get("scroll_offset"))
1518 .is_none(),
1519 "Project config should NOT contain scroll_offset (it's a system default)"
1520 );
1521
1522 drop(temp);
1523 }
1524
1525 #[test]
1531 #[ignore = "Known limitation: save_to_layer cannot remove values that match parent layer"]
1532 fn save_to_layer_removes_inherited_values() {
1533 let (temp, resolver) = create_test_resolver();
1534
1535 let user_config_path = resolver.user_config_path();
1537 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1538 std::fs::write(&user_config_path, r#"{"editor": {"tab_size": 2}}"#).unwrap();
1539
1540 let project_config_path = resolver.project_config_write_path();
1542 std::fs::create_dir_all(project_config_path.parent().unwrap()).unwrap();
1543 std::fs::write(&project_config_path, r#"{"editor": {"tab_size": 8}}"#).unwrap();
1544
1545 let mut config = resolver.resolve().unwrap();
1547 assert_eq!(config.editor.tab_size, 8);
1548
1549 config.editor.tab_size = 2;
1551
1552 resolver
1554 .save_to_layer(&config, ConfigLayer::Project)
1555 .unwrap();
1556
1557 let content = std::fs::read_to_string(&project_config_path).unwrap();
1559 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1560
1561 assert!(
1563 json.get("editor").and_then(|e| e.get("tab_size")).is_none(),
1564 "Project config should NOT contain tab_size when it matches user layer"
1565 );
1566
1567 drop(temp);
1568 }
1569
1570 #[test]
1578 fn issue_630_save_to_file_strips_settings_matching_defaults() {
1579 let (_temp, resolver) = create_test_resolver();
1580
1581 let user_config_path = resolver.user_config_path();
1583 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1584 std::fs::write(
1585 &user_config_path,
1586 r#"{
1587 "theme": "dracula",
1588 "editor": {
1589 "tab_size": 2
1590 }
1591 }"#,
1592 )
1593 .unwrap();
1594
1595 let mut config = resolver.resolve().unwrap();
1597 assert_eq!(config.theme.0, "dracula");
1598 assert_eq!(config.editor.tab_size, 2);
1599
1600 if let Some(lsp_configs) = config.lsp.get_mut("python") {
1602 for c in lsp_configs.as_mut_slice().iter_mut() {
1603 c.enabled = false;
1604 }
1605 }
1606
1607 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1609
1610 let content = std::fs::read_to_string(&user_config_path).unwrap();
1612 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
1613
1614 eprintln!(
1615 "Saved config:\n{}",
1616 serde_json::to_string_pretty(&json).unwrap()
1617 );
1618
1619 assert_eq!(
1621 json.get("theme").and_then(|v| v.as_str()),
1622 Some("dracula"),
1623 "Theme should be saved (differs from default)"
1624 );
1625 assert_eq!(
1626 json.get("editor")
1627 .and_then(|e| e.get("tab_size"))
1628 .and_then(|v| v.as_u64()),
1629 Some(2),
1630 "tab_size should be saved (differs from default)"
1631 );
1632 assert_eq!(
1633 json.get("lsp")
1634 .and_then(|l| l.get("python"))
1635 .and_then(|p| p.get("enabled"))
1636 .and_then(|v| v.as_bool()),
1637 Some(false),
1638 "lsp.python.enabled should be saved (differs from default)"
1639 );
1640
1641 let reloaded = resolver.resolve().unwrap();
1643 assert_eq!(reloaded.theme.0, "dracula");
1644 assert_eq!(reloaded.editor.tab_size, 2);
1645 assert!(!reloaded.lsp["python"].as_slice()[0].enabled);
1646 assert_eq!(reloaded.lsp["python"].as_slice()[0].command, "pylsp");
1648 }
1649
1650 #[test]
1657 fn toggle_lsp_preserves_command() {
1658 let (_temp, resolver) = create_test_resolver();
1659 let user_config_path = resolver.user_config_path();
1660 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1661
1662 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1664
1665 let config = resolver.resolve().unwrap();
1667 let original_command = config.lsp["python"].as_slice()[0].command.clone();
1668 assert!(
1669 !original_command.is_empty(),
1670 "Default python LSP should have a command"
1671 );
1672
1673 let mut config = resolver.resolve().unwrap();
1675 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = false;
1676 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1677
1678 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1680 assert!(
1681 !saved_content.contains(r#""command""#),
1682 "Saved config should not contain 'command' field. File content: {}",
1683 saved_content
1684 );
1685 assert!(
1686 !saved_content.contains(r#""args""#),
1687 "Saved config should not contain 'args' field. File content: {}",
1688 saved_content
1689 );
1690
1691 let mut config = resolver.resolve().unwrap();
1693 assert!(!config.lsp["python"].as_slice()[0].enabled);
1694 config.lsp.get_mut("python").unwrap().as_mut_slice()[0].enabled = true;
1695 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
1696
1697 let config = resolver.resolve().unwrap();
1699 assert_eq!(
1700 config.lsp["python"].as_slice()[0].command,
1701 original_command,
1702 "Command should be preserved after toggling enabled. Got: '{}'",
1703 config.lsp["python"].as_slice()[0].command
1704 );
1705 }
1706
1707 #[test]
1718 fn issue_631_disabled_lsp_without_command_should_be_valid() {
1719 let (_temp, resolver) = create_test_resolver();
1720
1721 let user_config_path = resolver.user_config_path();
1723 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1724 std::fs::write(
1725 &user_config_path,
1726 r#"{
1727 "lsp": {
1728 "json": { "enabled": false },
1729 "python": { "enabled": false },
1730 "toml": { "enabled": false }
1731 },
1732 "theme": "dracula"
1733 }"#,
1734 )
1735 .unwrap();
1736
1737 let result = resolver.resolve();
1739
1740 assert!(
1743 result.is_ok(),
1744 "BUG #631: Config with disabled LSP should be valid even without 'command' field. \
1745 Got parse error: {:?}",
1746 result.err()
1747 );
1748
1749 let config = result.unwrap();
1751 assert_eq!(
1752 config.theme.0, "dracula",
1753 "Theme should be 'dracula' from config file"
1754 );
1755 }
1756
1757 #[test]
1759 fn loading_lsp_without_command_uses_default() {
1760 let (_temp, resolver) = create_test_resolver();
1761 let user_config_path = resolver.user_config_path();
1762 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1763
1764 std::fs::write(
1766 &user_config_path,
1767 r#"{ "lsp": { "rust": { "enabled": false } } }"#,
1768 )
1769 .unwrap();
1770
1771 let config = resolver.resolve().unwrap();
1773 assert_eq!(
1774 config.lsp["rust"].as_slice()[0].command,
1775 "rust-analyzer",
1776 "Command should come from defaults when not in file. Got: '{}'",
1777 config.lsp["rust"].as_slice()[0].command
1778 );
1779 assert!(
1780 !config.lsp["rust"].as_slice()[0].enabled,
1781 "enabled should be false from file"
1782 );
1783 }
1784
1785 #[test]
1791 fn settings_ui_toggle_lsp_preserves_command() {
1792 let (_temp, resolver) = create_test_resolver();
1793 let user_config_path = resolver.user_config_path();
1794 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1795
1796 std::fs::write(&user_config_path, r#"{}"#).unwrap();
1798
1799 let config = resolver.resolve().unwrap();
1801 assert_eq!(
1802 config.lsp["rust"].as_slice()[0].command,
1803 "rust-analyzer",
1804 "Default rust command should be rust-analyzer"
1805 );
1806 assert!(
1807 config.lsp["rust"].as_slice()[0].enabled,
1808 "Default rust enabled should be true"
1809 );
1810
1811 let mut changes = std::collections::HashMap::new();
1814 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(false));
1815 let deletions = std::collections::HashSet::new();
1816
1817 resolver
1819 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1820 .unwrap();
1821
1822 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1824 eprintln!("After disable, file contains:\n{}", saved_content);
1825
1826 let reloaded = resolver.resolve().unwrap();
1828 assert_eq!(
1829 reloaded.lsp["rust"].as_slice()[0].command,
1830 "rust-analyzer",
1831 "Command should be preserved after save/reload (disabled). Got: '{}'",
1832 reloaded.lsp["rust"].as_slice()[0].command
1833 );
1834 assert!(
1835 !reloaded.lsp["rust"].as_slice()[0].enabled,
1836 "rust should be disabled"
1837 );
1838
1839 let mut changes = std::collections::HashMap::new();
1841 changes.insert("/lsp/rust/enabled".to_string(), serde_json::json!(true));
1842 let deletions = std::collections::HashSet::new();
1843
1844 resolver
1846 .save_changes_to_layer(&changes, &deletions, ConfigLayer::User)
1847 .unwrap();
1848
1849 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1851 eprintln!("After re-enable, file contains:\n{}", saved_content);
1852
1853 let final_config = resolver.resolve().unwrap();
1855 assert_eq!(
1856 final_config.lsp["rust"].as_slice()[0].command,
1857 "rust-analyzer",
1858 "Command should be preserved after toggle cycle. Got: '{}'",
1859 final_config.lsp["rust"].as_slice()[0].command
1860 );
1861 assert!(
1862 final_config.lsp["rust"].as_slice()[0].enabled,
1863 "rust should be enabled"
1864 );
1865 }
1866
1867 #[test]
1878 fn issue_806_manual_config_edits_lost_when_saving_from_ui() {
1879 let (_temp, resolver) = create_test_resolver();
1880 let user_config_path = resolver.user_config_path();
1881 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
1882
1883 std::fs::write(
1886 &user_config_path,
1887 r#"{
1888 "lsp": {
1889 "rust-analyzer": {
1890 "enabled": true,
1891 "command": "rust-analyzer",
1892 "args": ["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1893 "languages": ["rust"]
1894 }
1895 }
1896 }"#,
1897 )
1898 .unwrap();
1899
1900 let config = resolver.resolve().unwrap();
1902
1903 assert!(
1905 config.lsp.contains_key("rust-analyzer"),
1906 "Config should contain manually-added 'rust-analyzer' LSP entry"
1907 );
1908 let rust_analyzer = &config.lsp["rust-analyzer"].as_slice()[0];
1909 assert!(rust_analyzer.enabled, "rust-analyzer should be enabled");
1910 assert_eq!(
1911 rust_analyzer.command, "rust-analyzer",
1912 "rust-analyzer command should be preserved"
1913 );
1914 assert_eq!(
1915 rust_analyzer.args,
1916 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
1917 "rust-analyzer args should be preserved"
1918 );
1919
1920 let mut config_json = serde_json::to_value(&config).unwrap();
1923 *config_json
1924 .pointer_mut("/editor/tab_size")
1925 .expect("path should exist") = serde_json::json!(2);
1926 let modified_config: crate::config::Config =
1927 serde_json::from_value(config_json).expect("should deserialize");
1928
1929 resolver
1931 .save_to_layer(&modified_config, ConfigLayer::User)
1932 .unwrap();
1933
1934 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
1936 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
1937
1938 eprintln!(
1939 "Issue #806 - Saved config after changing tab_size:\n{}",
1940 serde_json::to_string_pretty(&saved_json).unwrap()
1941 );
1942
1943 assert!(
1945 saved_json.get("lsp").is_some(),
1946 "BUG #806: 'lsp' section should NOT be deleted when saving unrelated changes. \
1947 File content: {}",
1948 saved_content
1949 );
1950
1951 assert!(
1952 saved_json
1953 .get("lsp")
1954 .and_then(|l| l.get("rust-analyzer"))
1955 .is_some(),
1956 "BUG #806: 'lsp.rust-analyzer' should NOT be deleted when saving unrelated changes. \
1957 File content: {}",
1958 saved_content
1959 );
1960
1961 let saved_args = saved_json
1963 .get("lsp")
1964 .and_then(|l| l.get("rust-analyzer"))
1965 .and_then(|r| r.get("args"));
1966 assert!(
1967 saved_args.is_some(),
1968 "BUG #806: 'lsp.rust-analyzer.args' should be preserved. File content: {}",
1969 saved_content
1970 );
1971 assert_eq!(
1972 saved_args.unwrap(),
1973 &serde_json::json!(["--log-file", "/tmp/rust-analyzer-{pid}.log"]),
1974 "BUG #806: Custom args should be preserved exactly"
1975 );
1976
1977 assert_eq!(
1979 saved_json
1980 .get("editor")
1981 .and_then(|e| e.get("tab_size"))
1982 .and_then(|v| v.as_u64()),
1983 Some(2),
1984 "tab_size should be saved"
1985 );
1986
1987 let reloaded = resolver.resolve().unwrap();
1989 assert_eq!(
1990 reloaded.editor.tab_size, 2,
1991 "tab_size change should be persisted"
1992 );
1993 assert!(
1994 reloaded.lsp.contains_key("rust-analyzer"),
1995 "BUG #806: rust-analyzer should still exist after reload"
1996 );
1997 let reloaded_ra = &reloaded.lsp["rust-analyzer"].as_slice()[0];
1998 assert_eq!(
1999 reloaded_ra.args,
2000 vec!["--log-file", "/tmp/rust-analyzer-{pid}.log"],
2001 "BUG #806: Custom args should survive save/reload cycle"
2002 );
2003 }
2004
2005 #[test]
2010 fn issue_806_custom_lsp_entries_preserved_across_unrelated_changes() {
2011 let (_temp, resolver) = create_test_resolver();
2012 let user_config_path = resolver.user_config_path();
2013 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2014
2015 std::fs::write(
2017 &user_config_path,
2018 r#"{
2019 "theme": "dracula",
2020 "lsp": {
2021 "my-custom-lsp": {
2022 "enabled": true,
2023 "command": "/usr/local/bin/my-custom-lsp",
2024 "args": ["--verbose", "--config", "/etc/my-lsp.json"],
2025 "languages": ["mycustomlang"]
2026 }
2027 },
2028 "languages": {
2029 "mycustomlang": {
2030 "extensions": [".mcl"],
2031 "grammar": "mycustomlang"
2032 }
2033 }
2034 }"#,
2035 )
2036 .unwrap();
2037
2038 let config = resolver.resolve().unwrap();
2040 assert!(
2041 config.lsp.contains_key("my-custom-lsp"),
2042 "Custom LSP entry should be loaded"
2043 );
2044 assert!(
2045 config.languages.contains_key("mycustomlang"),
2046 "Custom language should be loaded"
2047 );
2048
2049 let mut config_json = serde_json::to_value(&config).unwrap();
2051 *config_json
2052 .pointer_mut("/editor/line_numbers")
2053 .expect("path should exist") = serde_json::json!(false);
2054 let modified_config: crate::config::Config =
2055 serde_json::from_value(config_json).expect("should deserialize");
2056
2057 resolver
2059 .save_to_layer(&modified_config, ConfigLayer::User)
2060 .unwrap();
2061
2062 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2064 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2065
2066 eprintln!(
2067 "Saved config:\n{}",
2068 serde_json::to_string_pretty(&saved_json).unwrap()
2069 );
2070
2071 assert!(
2073 saved_json
2074 .get("lsp")
2075 .and_then(|l| l.get("my-custom-lsp"))
2076 .is_some(),
2077 "BUG #806: Custom LSP 'my-custom-lsp' should be preserved. Got: {}",
2078 saved_content
2079 );
2080
2081 assert!(
2083 saved_json
2084 .get("languages")
2085 .and_then(|l| l.get("mycustomlang"))
2086 .is_some(),
2087 "BUG #806: Custom language 'mycustomlang' should be preserved. Got: {}",
2088 saved_content
2089 );
2090
2091 let reloaded = resolver.resolve().unwrap();
2093 assert!(
2094 reloaded.lsp.contains_key("my-custom-lsp"),
2095 "Custom LSP should survive save/reload"
2096 );
2097 assert!(
2098 reloaded.languages.contains_key("mycustomlang"),
2099 "Custom language should survive save/reload"
2100 );
2101 assert!(
2102 !reloaded.editor.line_numbers,
2103 "line_numbers change should be applied"
2104 );
2105 }
2106
2107 #[test]
2120 fn issue_806_external_file_modification_lost_on_ui_save() {
2121 let (_temp, resolver) = create_test_resolver();
2122 let user_config_path = resolver.user_config_path();
2123 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2124
2125 std::fs::write(&user_config_path, r#"{"theme": "monokai"}"#).unwrap();
2127
2128 let config_at_startup = resolver.resolve().unwrap();
2130 assert_eq!(config_at_startup.theme.0, "monokai");
2131 assert!(
2132 !config_at_startup.lsp.contains_key("rust-analyzer"),
2133 "No custom LSP at startup"
2134 );
2135
2136 std::fs::write(
2139 &user_config_path,
2140 r#"{
2141 "theme": "monokai",
2142 "lsp": {
2143 "rust-analyzer": {
2144 "enabled": true,
2145 "command": "rust-analyzer",
2146 "args": ["--log-file", "/tmp/ra.log"]
2147 }
2148 }
2149 }"#,
2150 )
2151 .unwrap();
2152
2153 let mut config_json = serde_json::to_value(&config_at_startup).unwrap();
2157 *config_json
2158 .pointer_mut("/editor/tab_size")
2159 .expect("path should exist") = serde_json::json!(2);
2160 let modified_config: crate::config::Config =
2161 serde_json::from_value(config_json).expect("should deserialize");
2162
2163 resolver
2167 .save_to_layer(&modified_config, ConfigLayer::User)
2168 .unwrap();
2169
2170 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2172 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2173
2174 eprintln!(
2175 "Issue #806 scenario 2 - After UI save (external edits should be preserved):\n{}",
2176 serde_json::to_string_pretty(&saved_json).unwrap()
2177 );
2178
2179 assert!(
2185 saved_json.get("lsp").is_some(),
2186 "BUG #806: External edits to config.json were lost! \
2187 The 'lsp' section added while Fresh was running should be preserved. \
2188 Saved content: {}",
2189 saved_content
2190 );
2191
2192 assert!(
2193 saved_json
2194 .get("lsp")
2195 .and_then(|l| l.get("rust-analyzer"))
2196 .is_some(),
2197 "BUG #806: rust-analyzer config should be preserved"
2198 );
2199 }
2200
2201 #[test]
2207 fn issue_806_concurrent_modification_scenario() {
2208 let (_temp, resolver) = create_test_resolver();
2209 let user_config_path = resolver.user_config_path();
2210 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2211
2212 std::fs::write(&user_config_path, r#"{}"#).unwrap();
2214
2215 let mut config = resolver.resolve().unwrap();
2217
2218 config.editor.tab_size = 8;
2220
2221 std::fs::write(
2223 &user_config_path,
2224 r#"{
2225 "lsp": {
2226 "custom-lsp": {
2227 "enabled": true,
2228 "command": "/usr/bin/custom-lsp"
2229 }
2230 }
2231 }"#,
2232 )
2233 .unwrap();
2234
2235 resolver.save_to_layer(&config, ConfigLayer::User).unwrap();
2238
2239 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2241 let saved_json: serde_json::Value = serde_json::from_str(&saved_content).unwrap();
2242
2243 eprintln!(
2244 "Concurrent modification scenario result:\n{}",
2245 serde_json::to_string_pretty(&saved_json).unwrap()
2246 );
2247
2248 assert_eq!(
2250 saved_json
2251 .get("editor")
2252 .and_then(|e| e.get("tab_size"))
2253 .and_then(|v| v.as_u64()),
2254 Some(8),
2255 "Our tab_size change should be saved"
2256 );
2257
2258 let lsp_preserved = saved_json.get("lsp").is_some();
2264 if !lsp_preserved {
2265 eprintln!(
2266 "NOTE: Concurrent file modifications are lost with current implementation. \
2267 This is expected behavior but could be improved with read-modify-write pattern."
2268 );
2269 }
2270 }
2271
2272 #[test]
2282 fn save_to_layer_changing_to_default_value_should_persist() {
2283 let (_temp, resolver) = create_test_resolver();
2284 let user_config_path = resolver.user_config_path();
2285 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2286
2287 std::fs::write(&user_config_path, r#"{"theme": "dracula"}"#).unwrap();
2289
2290 let baseline = resolver.resolve().unwrap();
2292 assert_eq!(
2293 baseline.theme.0, "dracula",
2294 "Theme should be 'dracula' from file"
2295 );
2296
2297 let mut config = baseline.clone();
2299 config.theme = crate::config::ThemeName::from("high-contrast");
2300
2301 resolver
2303 .save_to_layer_with_baseline(&config, &baseline, ConfigLayer::User)
2304 .unwrap();
2305
2306 let saved_content = std::fs::read_to_string(&user_config_path).unwrap();
2308 eprintln!(
2309 "Saved config after changing to default theme:\n{}",
2310 saved_content
2311 );
2312
2313 let reloaded = resolver.resolve().unwrap();
2315
2316 assert_eq!(
2318 reloaded.theme.0, "high-contrast",
2319 "Theme should be 'high-contrast' after changing to default and saving. \
2320 With save_to_layer_with_baseline, the theme field should be removed from file \
2321 so the default applies. File content: {}",
2322 saved_content
2323 );
2324 }
2325
2326 #[test]
2329 fn universal_lsp_round_trip_via_config_resolver() {
2330 let (_temp, resolver) = create_test_resolver();
2331 let user_config_path = resolver.user_config_path();
2332 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2333
2334 std::fs::write(
2336 &user_config_path,
2337 r#"{
2338 "universal_lsp": {
2339 "quicklsp": { "enabled": true, "auto_start": true }
2340 }
2341 }"#,
2342 )
2343 .unwrap();
2344
2345 let config = resolver.resolve().unwrap();
2346
2347 assert!(config.universal_lsp.contains_key("quicklsp"));
2349 let server = &config.universal_lsp["quicklsp"].as_slice()[0];
2350 assert!(server.enabled, "User override should enable quicklsp");
2351 assert!(server.auto_start, "User override should enable auto_start");
2352 assert_eq!(
2353 server.command, "quicklsp",
2354 "Command should come from defaults"
2355 );
2356 }
2357
2358 #[test]
2360 fn universal_lsp_custom_server_merges_with_defaults() {
2361 let (_temp, resolver) = create_test_resolver();
2362 let user_config_path = resolver.user_config_path();
2363 std::fs::create_dir_all(user_config_path.parent().unwrap()).unwrap();
2364
2365 std::fs::write(
2366 &user_config_path,
2367 r#"{
2368 "universal_lsp": {
2369 "my-universal-server": {
2370 "command": "my-server-bin",
2371 "enabled": true
2372 }
2373 }
2374 }"#,
2375 )
2376 .unwrap();
2377
2378 let config = resolver.resolve().unwrap();
2379
2380 assert!(
2382 config.universal_lsp.contains_key("my-universal-server"),
2383 "Custom universal server should be loaded"
2384 );
2385 assert_eq!(
2386 config.universal_lsp["my-universal-server"].as_slice()[0].command,
2387 "my-server-bin"
2388 );
2389
2390 assert!(
2392 config.universal_lsp.contains_key("quicklsp"),
2393 "Default quicklsp should be preserved when adding custom servers"
2394 );
2395 }
2396
2397 #[test]
2401 fn universal_lsp_partial_config_round_trip() {
2402 use crate::partial_config::PartialConfig;
2403
2404 let mut config = Config::default();
2405 if let Some(quicklsp) = config.universal_lsp.get_mut("quicklsp") {
2407 quicklsp.as_mut_slice()[0].enabled = true;
2408 }
2409
2410 let partial = PartialConfig::from(&config);
2412 let resolved = partial.resolve();
2413
2414 assert!(
2416 resolved.universal_lsp.contains_key("quicklsp"),
2417 "quicklsp should survive Config -> PartialConfig -> Config round trip"
2418 );
2419 assert!(
2420 resolved.universal_lsp["quicklsp"].as_slice()[0].enabled,
2421 "quicklsp enabled state should be preserved through round trip"
2422 );
2423 }
2424}