Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output

# Rust
target/
Cargo.lock

# macOS
.DS_Store
.AppleDouble
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[workspace]
members = ["fig-core", "fig-ui"]
resolver = "2"
22 changes: 22 additions & 0 deletions fig-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "fig-core"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
dirs = "6"
notify = "7"
tokio = { version = "1", features = ["full"] }
log = "0.4"
env_logger = "0.11"
reqwest = { version = "0.12", features = ["json"] }
arboard = "3"
walkdir = "2"

[dev-dependencies]
tempfile = "3"
217 changes: 217 additions & 0 deletions fig-core/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum FigError {
#[error("Configuration error: {0}")]
Config(#[from] ConfigFileError),

#[error("Health check error: {0}")]
HealthCheck(String),

#[error("MCP error: {0}")]
Mcp(String),

#[error("Bundle error: {0}")]
Bundle(#[from] ConfigBundleError),

#[error("IO error: {0}")]
Io(#[from] std::io::Error),

#[error("{0}")]
Other(String),
}

#[derive(Debug, Error)]
pub enum ConfigFileError {
#[error("File not found: {path}")]
FileNotFound { path: PathBuf },

#[error("Permission denied: {path}")]
PermissionDenied { path: PathBuf },

#[error("Invalid JSON in {path}: {message}")]
InvalidJson { path: PathBuf, message: String },

#[error("Failed to write {path}: {message}")]
WriteError { path: PathBuf, message: String },

#[error("Backup failed for {path}: {message}")]
BackupFailed { path: PathBuf, message: String },

#[error("Circular symlink detected at {path}")]
CircularSymlink { path: PathBuf },
}

impl ConfigFileError {
pub fn recovery_suggestion(&self) -> &str {
match self {
Self::FileNotFound { .. } => "The file will be created when you save settings.",
Self::PermissionDenied { .. } => "Check file permissions and try again.",
Self::InvalidJson { .. } => {
"The file contains invalid JSON. Fix it manually or delete it to start fresh."
}
Self::WriteError { .. } => "Check disk space and file permissions.",
Self::BackupFailed { .. } => "Check disk space. The original file was not modified.",
Self::CircularSymlink { .. } => "Remove the circular symlink and try again.",
}
}
}

#[derive(Debug, Error)]
pub enum ConfigBundleError {
#[error("Invalid bundle format: {0}")]
InvalidFormat(String),

#[error("Unsupported bundle version: {version}")]
UnsupportedVersion { version: String },

#[error("Export failed: {0}")]
ExportFailed(String),

#[error("Import failed: {0}")]
ImportFailed(String),

#[error("No components selected for export")]
NoComponentsSelected,

#[error("Project not found: {path}")]
ProjectNotFound { path: PathBuf },
}

#[derive(Debug, Error)]
pub enum MCPHealthCheckError {
#[error("Failed to spawn process: {0}")]
ProcessSpawnFailed(String),

#[error("Process exited early with code {code}")]
ProcessExitedEarly { code: i32 },

#[error("Invalid handshake response: {0}")]
InvalidHandshakeResponse(String),

#[error("HTTP request failed with status {status}")]
HttpRequestFailed { status: u16 },

#[error("Network error: {0}")]
NetworkError(String),

#[error("Health check timed out after {seconds}s")]
Timeout { seconds: u64 },

#[error("Server has no command or URL configured")]
NoCommandOrUrl,
}

impl MCPHealthCheckError {
pub fn recovery_suggestion(&self) -> &str {
match self {
Self::ProcessSpawnFailed(_) => "Check that the command exists and is in your PATH.",
Self::ProcessExitedEarly { .. } => "The server crashed on startup. Check its logs.",
Self::InvalidHandshakeResponse(_) => {
"The server did not respond with valid MCP protocol."
}
Self::HttpRequestFailed { .. } => "Check the server URL and that it's running.",
Self::NetworkError(_) => "Check your network connection and the server URL.",
Self::Timeout { .. } => "The server took too long to respond. It may be overloaded.",
Self::NoCommandOrUrl => {
"Configure either a command (stdio) or URL (HTTP) for this server."
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_config_file_error_display() {
let err = ConfigFileError::FileNotFound {
path: PathBuf::from("/test/path.json"),
};
assert!(format!("{err}").contains("/test/path.json"));

let err = ConfigFileError::InvalidJson {
path: PathBuf::from("/test.json"),
message: "unexpected EOF".to_string(),
};
assert!(format!("{err}").contains("/test.json"));
assert!(format!("{err}").contains("unexpected EOF"));
}

#[test]
fn test_fig_error_from_config() {
let config_err = ConfigFileError::FileNotFound {
path: PathBuf::from("/test"),
};
let fig_err: FigError = config_err.into();
assert!(matches!(fig_err, FigError::Config(_)));
}

#[test]
fn test_fig_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let fig_err: FigError = io_err.into();
assert!(matches!(fig_err, FigError::Io(_)));
}

#[test]
fn test_fig_error_from_bundle() {
let bundle_err = ConfigBundleError::NoComponentsSelected;
let fig_err: FigError = bundle_err.into();
assert!(matches!(fig_err, FigError::Bundle(_)));
}

#[test]
fn test_recovery_suggestions_non_empty() {
let errors = vec![
ConfigFileError::FileNotFound {
path: PathBuf::new(),
},
ConfigFileError::PermissionDenied {
path: PathBuf::new(),
},
ConfigFileError::InvalidJson {
path: PathBuf::new(),
message: String::new(),
},
ConfigFileError::WriteError {
path: PathBuf::new(),
message: String::new(),
},
ConfigFileError::BackupFailed {
path: PathBuf::new(),
message: String::new(),
},
ConfigFileError::CircularSymlink {
path: PathBuf::new(),
},
];
for err in &errors {
assert!(
!err.recovery_suggestion().is_empty(),
"Empty recovery suggestion for {err}"
);
}
}

#[test]
fn test_mcp_health_check_recovery_suggestions_non_empty() {
let errors: Vec<MCPHealthCheckError> = vec![
MCPHealthCheckError::ProcessSpawnFailed("test".into()),
MCPHealthCheckError::ProcessExitedEarly { code: 1 },
MCPHealthCheckError::InvalidHandshakeResponse("test".into()),
MCPHealthCheckError::HttpRequestFailed { status: 500 },
MCPHealthCheckError::NetworkError("test".into()),
MCPHealthCheckError::Timeout { seconds: 30 },
MCPHealthCheckError::NoCommandOrUrl,
];
for err in &errors {
assert!(
!err.recovery_suggestion().is_empty(),
"Empty recovery suggestion for {err}"
);
}
}
}
3 changes: 3 additions & 0 deletions fig-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod error;
pub mod models;
pub mod services;
53 changes: 53 additions & 0 deletions fig-core/src/models/attribution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Attribution {
#[serde(skip_serializing_if = "Option::is_none")]
pub commits: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "pullRequests")]
pub pull_requests: Option<bool>,
#[serde(flatten)]
pub additional_properties: HashMap<String, Value>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_attribution_round_trip() {
let json = r#"{"commits":true,"pullRequests":false,"unknownField":123}"#;
let parsed: Attribution = serde_json::from_str(json).unwrap();
assert_eq!(parsed.commits, Some(true));
assert_eq!(parsed.pull_requests, Some(false));
assert_eq!(
parsed.additional_properties.get("unknownField"),
Some(&serde_json::json!(123))
);

let re_serialized = serde_json::to_string(&parsed).unwrap();
let re_parsed: Attribution = serde_json::from_str(&re_serialized).unwrap();
assert_eq!(parsed, re_parsed);
}

#[test]
fn test_attribution_empty() {
let parsed: Attribution = serde_json::from_str("{}").unwrap();
assert_eq!(parsed, Attribution::default());
}

#[test]
fn test_attribution_camel_case_rename() {
let a = Attribution {
commits: Some(true),
pull_requests: Some(false),
..Default::default()
};
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("pullRequests"));
assert!(!json.contains("pull_requests"));
}
}
Loading