Skip to main content

braid_core/fs/
sync.rs

1use crate::core::{protocol_mod as protocol, BraidError, Result};
2use crate::fs::state::DaemonState;
3use braid_http::types::{BraidRequest, Version as BraidVersion};
4use std::path::PathBuf;
5use std::process::Command;
6use tracing::{error, info};
7
8/// Logic for syncing a local file to a remote Braid URL.
9pub async fn sync_local_to_remote(
10    _path: &PathBuf,
11    url_in: &str,
12    parents: &[String],
13    _original_content: Option<String>,
14    new_content: String,
15    content_type: Option<String>,
16    state: DaemonState,
17) -> Result<()> {
18    let url_str = url_in.trim_matches('"').trim().to_string();
19    info!("[BraidFS] Syncing {} to remote...", url_str);
20
21    // 1. Special case for braid.org using subprocess curl for max compatibility
22    // This bypasses history-related 309 Conflicts for Braid Wiki resources.
23    if url_str.contains("braid.org") {
24        let mut cookie_str = String::new();
25        let mut parents_header = String::new();
26
27        if let Ok(u) = url::Url::parse(&url_str) {
28            if let Some(domain) = u.domain() {
29                // 1. Fetch Auth
30                let cfg = state.config.read().await;
31                if let Some(token) = cfg.cookies.get(domain) {
32                    cookie_str = if token.contains('=') {
33                        token.clone()
34                    } else {
35                        format!("client={}", token)
36                    };
37                }
38            }
39        }
40
41        // 2. Proactively fetch latest version (Parents) to avoid 309 Conflict
42        let mut head_req = BraidRequest::new().with_method("GET");
43        if !cookie_str.is_empty() {
44            head_req = head_req.with_header("Cookie", cookie_str.clone());
45        }
46
47        if let Ok(res) = state.client.fetch(&url_str, head_req).await {
48            if let Some(v_header) = res.header("version").or(res.header("current-version")) {
49                parents_header = v_header.to_string();
50                info!("[BraidFS] Found parents for braid.org: {}", parents_header);
51            }
52        }
53
54        let mut curl_cmd = Command::new("curl.exe");
55        curl_cmd
56            .arg("-i")
57            .arg("-s")
58            .arg("-X")
59            .arg("PUT")
60            .arg(&url_str);
61
62        if !cookie_str.is_empty() {
63            curl_cmd.arg("-H").arg(format!("Cookie: {}", cookie_str));
64        }
65
66        if !parents_header.is_empty() {
67            curl_cmd
68                .arg("-H")
69                .arg(format!("Parents: {}", parents_header));
70        }
71
72        let output = curl_cmd
73            .arg("-H")
74            .arg("Accept: */*")
75            .arg("-H")
76            .arg("User-Agent: curl/8.0.1")
77            .arg("-d")
78            .arg(&new_content)
79            .output();
80
81        match output {
82            Ok(out) => {
83                let stdout = String::from_utf8_lossy(&out.stdout);
84                let stderr = String::from_utf8_lossy(&out.stderr);
85
86                if stdout.contains("200 OK")
87                    || stdout.contains("209 Subscription")
88                    || stdout.contains("204 No Content")
89                    || stdout.contains("201 Created")
90                {
91                    state.failed_syncs.write().await.remove(&url_str);
92                    info!("[BraidFS] Sync success (curl) for {}", url_str);
93                    state
94                        .content_cache
95                        .write()
96                        .await
97                        .insert(url_str.clone(), new_content.clone());
98                    return Ok(());
99                } else {
100                    let status_line = stdout.lines().next().unwrap_or("Unknown").to_string();
101                    let status_code = if status_line.contains("309") {
102                        309
103                    } else {
104                        500
105                    };
106                    let err_msg = format!(
107                        "Sync failed (curl). Status: {}. Stderr: {}",
108                        status_line, stderr
109                    );
110                    error!("[BraidFS] {}", err_msg);
111                    state
112                        .failed_syncs
113                        .write()
114                        .await
115                        .insert(url_str.clone(), (status_code, std::time::Instant::now()));
116                    return Err(BraidError::Http(err_msg));
117                }
118            }
119            Err(e) => {
120                error!("[BraidFS] Failed to spawn curl: {}", e);
121                return Err(BraidError::Io(e));
122            }
123        }
124    }
125
126    // 2. Standard Braid Protocol Path
127    let mut request = BraidRequest::new().with_method("PUT");
128    let mut effective_parents = parents.to_vec();
129
130    if effective_parents.is_empty() {
131        let mut head_req = BraidRequest::new()
132            .with_method("GET")
133            .with_header("Accept", "text/plain");
134
135        if let Ok(u) = url::Url::parse(&url_str) {
136            if let Some(domain) = u.domain() {
137                let cfg = state.config.read().await;
138                if let Some(token) = cfg.cookies.get(domain) {
139                    let cookie_str = if token.contains('=') {
140                        token.clone()
141                    } else {
142                        format!("token={}", token)
143                    };
144                    head_req = head_req.with_header("Cookie", cookie_str);
145                }
146            }
147        }
148
149        if let Ok(res) = state.client.fetch(&url_str, head_req).await {
150            if let Some(v_header) = res
151                .headers
152                .get("version")
153                .or(res.headers.get("current-version"))
154            {
155                if let Ok(versions) = protocol::parse_version_header(v_header) {
156                    for v in versions {
157                        let v_str = match v {
158                            BraidVersion::String(s) => s,
159                            BraidVersion::Integer(i) => i.to_string(),
160                        };
161                        let normalized = v_str.trim_matches('"').to_string();
162                        if !normalized.is_empty() {
163                            effective_parents.push(normalized);
164                        }
165                    }
166                }
167            }
168        }
169    }
170
171    if !effective_parents.is_empty() {
172        let filtered_parents: Vec<BraidVersion> = effective_parents
173            .iter()
174            .filter(|p| !p.starts_with("temp-") && !p.starts_with("missing-"))
175            .map(|p| BraidVersion::new(p))
176            .collect();
177
178        if !filtered_parents.is_empty() {
179            request = request.with_parents(filtered_parents);
180        }
181    }
182
183    let ct = content_type.unwrap_or_else(|| "text/plain".to_string());
184    request = request.with_content_type(ct);
185    let mut final_request = request.with_body(new_content.clone());
186
187    if let Ok(u) = url::Url::parse(&url_str) {
188        if let Some(domain) = u.domain() {
189            let cfg = state.config.read().await;
190            if let Some(token) = cfg.cookies.get(domain) {
191                let cookie_str = if token.contains('=') {
192                    token.clone()
193                } else {
194                    format!("token={}", token)
195                };
196                final_request = final_request.with_header("Cookie", cookie_str);
197            }
198        }
199    }
200
201    let status = match state.client.fetch(&url_str, final_request).await {
202        Ok(res) => {
203            if (200..300).contains(&res.status) {
204                state.failed_syncs.write().await.remove(&url_str);
205                info!("[BraidFS] Sync success (braid) status: {}", res.status);
206                state
207                    .content_cache
208                    .write()
209                    .await
210                    .insert(url_str.clone(), new_content);
211                return Ok(());
212            }
213            res.status
214        }
215        Err(e) => {
216            error!("[BraidFS] Sync error: {}", e);
217            500
218        }
219    };
220
221    let err_msg = format!("Sync failed: HTTP {}", status);
222    state
223        .failed_syncs
224        .write()
225        .await
226        .insert(url_str, (status, std::time::Instant::now()));
227    Err(BraidError::Http(err_msg))
228}
229
230/// Logic for syncing a local binary file to a remote Braid URL.
231pub async fn sync_binary_to_remote(
232    _path: &std::path::Path,
233    url_in: &str,
234    parents: &[String],
235    data: bytes::Bytes,
236    content_type: Option<String>,
237    state: DaemonState,
238) -> Result<()> {
239    let url_str = url_in.trim_matches('"').trim().to_string();
240    info!("[BraidFS] Syncing binary {} to remote...", url_str);
241
242    // 1. Proactively fetch latest version (Parents) to avoid 309 Conflict if not provided
243    let mut parents_header = String::new();
244    if parents.is_empty() {
245        let head_req = BraidRequest::new().with_method("GET");
246        if let Ok(res) = state.client.fetch(&url_str, head_req).await {
247            if let Some(v_header) = res.header("version").or(res.header("current-version")) {
248                parents_header = v_header.to_string();
249            }
250        }
251    } else {
252        // Simple join for now, protocol::format_version_header could be used but it's likely single parent usually
253        parents_header = format!("\"{}\"", parents.join("\", \""));
254    }
255
256    // 2. Determine auth
257    let mut cookie_str = String::new();
258    if let Ok(u) = url::Url::parse(&url_str) {
259        if let Some(domain) = u.domain() {
260            let cfg = state.config.read().await;
261            if let Some(token) = cfg.cookies.get(domain) {
262                cookie_str = if token.contains('=') {
263                    token.clone()
264                } else {
265                    format!("client={}", token)
266                };
267            }
268        }
269    }
270
271    // 3. Perform PUT using curl for maximum compatibility with braid.org
272    let mut curl_cmd = Command::new("curl.exe");
273    curl_cmd
274        .arg("-i")
275        .arg("-s")
276        .arg("-X")
277        .arg("PUT")
278        .arg(&url_str);
279
280    if !cookie_str.is_empty() {
281        curl_cmd.arg("-H").arg(format!("Cookie: {}", cookie_str));
282    }
283
284    if !parents_header.is_empty() {
285        curl_cmd
286            .arg("-H")
287            .arg(format!("Parents: {}", parents_header));
288    }
289
290    let ct = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
291    curl_cmd.arg("-H").arg(format!("Content-Type: {}", ct));
292
293    // For binary, we'll pipe the data to curl
294    use std::io::Write;
295    let mut child = curl_cmd
296        .stdin(std::process::Stdio::piped())
297        .stdout(std::process::Stdio::piped())
298        .stderr(std::process::Stdio::piped())
299        .spawn()
300        .map_err(|e| BraidError::Io(e))?;
301
302    let mut stdin = child
303        .stdin
304        .take()
305        .ok_or_else(|| BraidError::Fs("Failed to open stdin for curl".to_string()))?;
306    stdin.write_all(&data).map_err(|e| BraidError::Io(e))?;
307    drop(stdin);
308
309    let out = child.wait_with_output().map_err(|e| BraidError::Io(e))?;
310    let stdout = String::from_utf8_lossy(&out.stdout);
311    let stderr = String::from_utf8_lossy(&out.stderr);
312
313    if stdout.contains("200 OK")
314        || stdout.contains("209 Subscription")
315        || stdout.contains("204 No Content")
316        || stdout.contains("201 Created")
317    {
318        info!("[BraidFS] Binary sync success (curl) for {}", url_str);
319        return Ok(());
320    } else {
321        let status_line = stdout.lines().next().unwrap_or("Unknown").to_string();
322        let err_msg = format!(
323            "Binary sync failed (curl). Status: {}. Stderr: {}",
324            status_line, stderr
325        );
326        error!("[BraidFS] {}", err_msg);
327        return Err(BraidError::Http(err_msg));
328    }
329}