Skip to main content

flowsurface_data/
lib.rs

1pub mod aggr;
2pub mod audio;
3pub mod chart;
4pub mod config;
5pub mod layout;
6pub mod log;
7pub mod metadata;
8pub mod panel;
9pub mod stream;
10pub mod tickers_table;
11pub mod util;
12
13use std::fs::File;
14use std::io::{Read, Write};
15use std::path::PathBuf;
16
17pub use audio::AudioStream;
18pub use config::ScaleFactor;
19pub use config::network::{Network, TradeFetchMode};
20pub use config::sidebar::{self, Sidebar};
21pub use config::state::{Layouts, State};
22pub use config::theme::Theme;
23pub use config::timezone::UserTimezone;
24
25use ::log::{error, info, warn};
26pub use layout::{Dashboard, Layout, Pane};
27pub use metadata::{MarketMetadata, MetadataSource};
28
29pub const SAVED_STATE_PATH: &str = "saved-state.json";
30
31#[derive(thiserror::Error, Debug, Clone)]
32pub enum InternalError {
33    #[error("Fetch error: {0}")]
34    Fetch(String),
35    #[error("Layout error: {0}")]
36    Layout(String),
37}
38
39pub fn write_json_to_file(json: &str, file_name: &str) -> std::io::Result<()> {
40    let path = data_path(Some(file_name));
41
42    let parent = path.parent().ok_or_else(|| {
43        std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid state file path")
44    })?;
45
46    if !parent.exists() {
47        std::fs::create_dir_all(parent)?;
48    }
49
50    // Write to a temp file and rename so the previous good state survives a
51    // crash or partial write.
52    let tmp_path = path.with_extension("tmp");
53
54    let write_result = File::create(&tmp_path).and_then(|mut file| {
55        file.write_all(json.as_bytes())?;
56        file.sync_all()?;
57        std::fs::rename(&tmp_path, &path)
58    });
59
60    if let Err(e) = write_result {
61        let _ = std::fs::remove_file(&tmp_path);
62        return Err(e);
63    }
64    Ok(())
65}
66
67pub fn read_from_file(file_name: &str) -> Result<State, Box<dyn std::error::Error>> {
68    let path = data_path(Some(file_name));
69
70    let file_open_result = File::open(&path);
71    let mut file = match file_open_result {
72        Ok(file) => file,
73        Err(e) => return Err(Box::new(e)),
74    };
75
76    let mut contents = String::new();
77    if let Err(e) = file.read_to_string(&mut contents) {
78        return Err(Box::new(e));
79    }
80
81    match serde_json::from_str(&contents) {
82        Ok(state) => Ok(state),
83        Err(e) => {
84            // If parsing fails, backup the file
85            drop(file); // Close the file before renaming
86
87            // Create backup file with different name to prevent overwriting it
88            let backup_file_name = if let Some(pos) = file_name.rfind('.') {
89                format!("{}_old{}", &file_name[..pos], &file_name[pos..])
90            } else {
91                format!("{}_old", file_name)
92            };
93
94            let backup_path = data_path(Some(&backup_file_name));
95
96            if let Err(rename_err) = std::fs::rename(&path, &backup_path) {
97                warn!(
98                    "Failed to backup corrupted state file '{}' to '{}': {}",
99                    path.display(),
100                    backup_path.display(),
101                    rename_err
102                );
103            } else {
104                info!(
105                    "Backed up corrupted state file to '{}'. It can be restored manually.",
106                    backup_path.display()
107                );
108            }
109
110            Err(Box::new(e))
111        }
112    }
113}
114
115pub fn open_data_folder() -> Result<(), InternalError> {
116    let pathbuf = data_path(None);
117
118    if pathbuf.exists() {
119        if let Err(err) = open::that(&pathbuf) {
120            Err(InternalError::Layout(format!(
121                "Failed to open data folder: {:?}, error: {}",
122                pathbuf, err
123            )))
124        } else {
125            info!("Opened data folder: {:?}", pathbuf);
126            Ok(())
127        }
128    } else {
129        Err(InternalError::Layout(format!(
130            "Data folder does not exist: {:?}",
131            pathbuf
132        )))
133    }
134}
135
136pub fn open_url(url: &str) -> Result<(), InternalError> {
137    if let Err(err) = open::that(url) {
138        Err(InternalError::Layout(format!(
139            "Failed to open URL '{}': {}",
140            url, err
141        )))
142    } else {
143        info!("Opened URL: {url}");
144        Ok(())
145    }
146}
147
148pub fn data_path(path_name: Option<&str>) -> PathBuf {
149    if let Ok(path) = std::env::var("FLOWSURFACE_DATA_PATH") {
150        let root = PathBuf::from(path);
151        match path_name {
152            Some(name) => root.join(name),
153            None => root,
154        }
155    } else {
156        let data_dir = dirs_next::data_dir().unwrap_or_else(|| PathBuf::from("."));
157        if let Some(path_name) = path_name {
158            data_dir.join("flowsurface").join(path_name)
159        } else {
160            data_dir.join("flowsurface")
161        }
162    }
163}
164
165fn cleanup_directory(data_path: &PathBuf) -> usize {
166    if !data_path.exists() {
167        warn!("Data path {:?} does not exist, skipping cleanup", data_path);
168        return 0;
169    }
170
171    let re =
172        regex::Regex::new(r".*-(\d{4}-\d{2}-\d{2})\.zip$").expect("Cleanup regex pattern is valid");
173    let today = chrono::Local::now().date_naive();
174    let mut deleted_files = Vec::new();
175
176    let entries = match std::fs::read_dir(data_path) {
177        Ok(entries) => entries,
178        Err(e) => {
179            error!("Failed to read data directory {:?}: {}", data_path, e);
180            return 0;
181        }
182    };
183
184    for entry in entries.filter_map(Result::ok) {
185        let symbol_dir = match std::fs::read_dir(entry.path()) {
186            Ok(dir) => dir,
187            Err(e) => {
188                error!("Failed to read symbol directory {:?}: {}", entry.path(), e);
189                continue;
190            }
191        };
192
193        for file in symbol_dir.filter_map(Result::ok) {
194            let path = file.path();
195            let Some(filename) = path.to_str() else {
196                continue;
197            };
198
199            if let Some(cap) = re.captures(filename)
200                && let Ok(file_date) = chrono::NaiveDate::parse_from_str(&cap[1], "%Y-%m-%d")
201            {
202                let days_old = today.signed_duration_since(file_date).num_days();
203                if days_old > 4 {
204                    if let Err(e) = std::fs::remove_file(&path) {
205                        error!("Failed to remove old file {}: {}", filename, e);
206                    } else {
207                        deleted_files.push(filename.to_string());
208                        info!("Removed old file: {}", filename);
209                    }
210                }
211            }
212        }
213    }
214
215    deleted_files.len()
216}
217
218pub fn cleanup_old_market_data() -> usize {
219    let paths = ["um", "cm"].map(|market_type| {
220        data_path(Some(&format!(
221            "market_data/binance/data/futures/{}/daily/aggTrades",
222            market_type
223        )))
224    });
225
226    let total_deleted: usize = paths.iter().map(cleanup_directory).sum();
227
228    info!("File cleanup completed. Deleted {} files", total_deleted);
229    total_deleted
230}