use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub struct Paths;
impl Paths {
pub fn config_dir() -> PathBuf {
if let Ok(d) = std::env::var("FILAMENT_CONFIG_DIR") {
return PathBuf::from(d);
}
Self::platform_config_dir()
}
fn platform_config_dir() -> PathBuf {
if let Some(proj) = directories::ProjectDirs::from("", "", "filament") {
return proj.config_dir().to_path_buf();
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config").join("filament")
}
pub fn config_path(relative: impl AsRef<Path>) -> PathBuf {
Self::config_dir().join(relative)
}
pub fn migrate_legacy() {
let legacy = PathBuf::from(".config/filament");
if !legacy.is_dir() {
return;
}
let target = Self::config_dir();
if target == legacy || target.exists() {
return;
}
let _ = std::fs::create_dir_all(&target);
if let Ok(entries) = std::fs::read_dir(&legacy) {
for e in entries.flatten() {
let dest = target.join(e.file_name());
let _ = std::fs::copy(e.path(), &dest);
}
}
}
pub fn home_dir() -> PathBuf {
#[cfg(unix)]
{
if let Ok(h) = std::env::var("HOME") {
if !h.is_empty() {
return PathBuf::from(h);
}
}
}
#[cfg(windows)]
{
if let Ok(h) = std::env::var("USERPROFILE") {
if !h.is_empty() {
return PathBuf::from(h);
}
}
}
PathBuf::from(".")
}
pub fn shell_argv(shell_program: Option<&str>, shell_config: Option<&str>, shell_user: Option<&str>) -> (Vec<String>, bool) {
let shell = shell_program
.map(|s| s.to_string())
.or_else(|| std::env::var("FILAMENT_SHELL").ok().filter(|s| !s.is_empty()))
.or_else(|| shell_config.map(|s| s.to_string()))
.unwrap_or_else(|| Self::default_shell());
let parts: Vec<String> = shell.split_whitespace().map(|s| s.to_string()).collect();
#[cfg(unix)]
{
let argv = match shell_user {
Some(user) => vec!["runuser".into(), "-l".into(), user.into()],
None => parts,
};
(argv, true)
}
#[cfg(windows)]
{
(parts, false)
}
#[cfg(not(any(unix, windows)))]
{
(parts, true)
}
}
fn default_shell() -> String {
#[cfg(unix)]
{
std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| {
if Path::new("/bin/bash").exists() { "/bin/bash".into() } else { "/bin/sh".into() }
})
}
#[cfg(windows)]
{
if Path::new("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe").exists() {
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".into()
} else if Path::new("C:\\Windows\\System32\\cmd.exe").exists() {
"C:\\Windows\\System32\\cmd.exe".into()
} else {
"cmd.exe".into()
}
}
#[cfg(not(any(unix, windows)))]
{
"/bin/sh".into()
}
}
}
pub struct SecretFile;
impl SecretFile {
pub fn write(path: impl AsRef<Path>, data: &[u8]) -> io::Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
SecretFile::write_raw(path, data)?;
SecretFile::restrict(path)?;
Ok(())
}
pub fn write_str(path: impl AsRef<Path>, s: &str) -> io::Result<()> {
SecretFile::write(path, s.as_bytes())
}
pub fn restrict(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(windows)]
{
restrict_dacl(path)?;
}
Ok(())
}
fn write_raw(path: &Path, data: &[u8]) -> io::Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(data)?;
f.sync_all()?;
Ok(())
}
}
#[cfg(windows)]
fn restrict_dacl(path: &Path) -> io::Result<()> {
let user = std::env::var("USERNAME").unwrap_or_default();
if user.is_empty() {
return Ok(());
}
let path_str = path.display().to_string();
let _ = std::process::Command::new("icacls")
.args([&path_str, "/inheritance:r", "/grant:r", &format!("{user}:(F)"), "/Q"])
.output();
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceHost {
Systemd,
Launchd,
WindowsService,
None,
}
impl ServiceHost {
pub fn detect() -> Self {
#[cfg(target_os = "linux")]
{
if Self::has_systemd() {
return ServiceHost::Systemd;
}
ServiceHost::None
}
#[cfg(target_os = "macos")]
{
ServiceHost::Launchd
}
#[cfg(target_os = "windows")]
{
ServiceHost::WindowsService
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
ServiceHost::None
}
}
pub fn supports_install(&self) -> bool {
matches!(self, ServiceHost::Systemd)
}
pub fn install_instructions(&self) -> &'static str {
match self {
ServiceHost::Launchd => "On macOS, create a LaunchAgent plist in ~/Library/LaunchAgents/ and load it with launchctl.",
ServiceHost::WindowsService => "On Windows, create a Scheduled Task (trigger: at logon) or register a Service with sc.exe.",
ServiceHost::None => "No service manager detected. Start filament with `filament up` in a terminal, or configure your init system manually.",
ServiceHost::Systemd => "",
}
}
#[cfg(target_os = "linux")]
fn has_systemd() -> bool {
std::path::Path::new("/run/systemd/system").is_dir()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallSource {
Homebrew,
Winget,
Scoop,
Cargo,
SelfInstalled,
}
impl InstallSource {
pub fn detect() -> Self {
let path = match std::env::current_exe() {
Ok(p) => match p.canonicalize() {
Ok(c) => c,
Err(_) => p,
},
Err(_) => return InstallSource::SelfInstalled,
};
Self::classify(&path)
}
fn classify(path: &Path) -> Self {
let s = path.to_string_lossy().to_lowercase();
if s.contains("/cellar/") || s.contains("/homebrew/") || s.contains("/linuxbrew/") {
return InstallSource::Homebrew;
}
if s.contains("\\microsoft\\winget\\") || s.contains("/microsoft/winget/") {
return InstallSource::Winget;
}
if s.contains("\\scoop\\apps\\") || s.contains("/scoop/apps/") {
return InstallSource::Scoop;
}
if s.contains("/.cargo/bin") || s.contains("\\.cargo\\bin") {
return InstallSource::Cargo;
}
InstallSource::SelfInstalled
}
pub fn upgrade_hint(&self) -> &'static str {
match self {
InstallSource::Homebrew => "brew upgrade filament",
InstallSource::Winget => "winget upgrade Abdk4Moura.Filament",
InstallSource::Scoop => "scoop update filament",
InstallSource::Cargo => "cargo install filament-cli",
InstallSource::SelfInstalled => "",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShellKind {
Posix,
PowerShell,
Cmd,
}
pub struct ShellHost {
argv: Vec<String>,
kind: ShellKind,
}
impl ShellHost {
pub fn new(shell_argv: &[String]) -> Self {
let binary = shell_argv.first().map(|s| s.as_str()).unwrap_or("");
let name = Path::new(binary)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
let kind = if name.contains("pwsh") || name.contains("powershell") {
ShellKind::PowerShell
} else if name == "cmd.exe" || name == "cmd" {
ShellKind::Cmd
} else {
ShellKind::Posix
};
ShellHost {
argv: shell_argv.to_vec(),
kind,
}
}
pub fn interactive_args(&self) -> Vec<String> {
let mut args = self.argv.clone();
match self.kind {
ShellKind::Posix => {
if !args.iter().any(|a| a == "-l" || a == "--login") {
args.push("-l".into());
}
args
}
_ => args,
}
}
pub fn exec_cmd_args(&self, cmd: &str) -> Vec<String> {
let mut args = vec![self.argv[0].clone()];
match self.kind {
ShellKind::Posix => {
args.push("-c".into());
args.push(cmd.to_string());
}
ShellKind::PowerShell => {
args.push("-Command".into());
args.push(cmd.to_string());
}
ShellKind::Cmd => {
args.push("/c".into());
args.push(cmd.to_string());
}
}
args
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_returns_a_valid_variant() {
let host = ServiceHost::detect();
assert!(
matches!(
host,
ServiceHost::Systemd
| ServiceHost::Launchd
| ServiceHost::WindowsService
| ServiceHost::None
),
"detect returned a valid variant: {host:?}"
);
}
#[test]
fn systemd_supports_install_others_do_not() {
assert!(ServiceHost::Systemd.supports_install());
assert!(!ServiceHost::Launchd.supports_install());
assert!(!ServiceHost::WindowsService.supports_install());
assert!(!ServiceHost::None.supports_install());
}
#[test]
fn install_instructions_non_empty_when_no_backend() {
assert!(!ServiceHost::Launchd.install_instructions().is_empty());
assert!(!ServiceHost::WindowsService.install_instructions().is_empty());
assert!(!ServiceHost::None.install_instructions().is_empty());
assert!(ServiceHost::Systemd.install_instructions().is_empty());
}
#[test]
fn install_source_classify_brew() {
let p = Path::new("/opt/homebrew/Cellar/filament/0.4.1/bin/filament");
assert_eq!(InstallSource::classify(p), InstallSource::Homebrew);
let p2 = Path::new("/home/linuxbrew/.linuxbrew/bin/filament");
assert_eq!(InstallSource::classify(p2), InstallSource::Homebrew);
let p3 = Path::new("/usr/local/Cellar/filament/0.3.1/bin/filament");
assert_eq!(InstallSource::classify(p3), InstallSource::Homebrew);
}
#[test]
fn install_source_classify_winget() {
let p = Path::new("C:\\Users\\kabir\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Abdk4Moura.Filament_filament\\filament.exe");
assert_eq!(InstallSource::classify(p), InstallSource::Winget);
}
#[test]
fn install_source_classify_scoop() {
let p = Path::new("C:\\Users\\kabir\\scoop\\apps\\filament\\0.4.1\\filament.exe");
assert_eq!(InstallSource::classify(p), InstallSource::Scoop);
}
#[test]
fn install_source_classify_cargo() {
let p = Path::new("/home/kabir/.cargo/bin/filament");
assert_eq!(InstallSource::classify(p), InstallSource::Cargo);
}
#[test]
fn install_source_classify_self_installed() {
let p = Path::new("/home/kabir/.local/bin/filament");
assert_eq!(InstallSource::classify(p), InstallSource::SelfInstalled);
}
#[test]
fn install_source_upgrade_hints() {
assert_eq!(InstallSource::Homebrew.upgrade_hint(), "brew upgrade filament");
assert_eq!(InstallSource::Winget.upgrade_hint(), "winget upgrade Abdk4Moura.Filament");
assert_eq!(InstallSource::Cargo.upgrade_hint(), "cargo install filament-cli");
assert_eq!(InstallSource::SelfInstalled.upgrade_hint(), "");
}
#[test]
fn shell_host_interactive_posix_adds_login() {
let sh = ShellHost::new(&["/bin/bash".into()]);
assert!(sh.interactive_args().contains(&"-l".into()));
}
#[test]
fn shell_host_exec_posix_uses_minus_c() {
let sh = ShellHost::new(&["bash".into()]);
let args = sh.exec_cmd_args("echo hi");
assert_eq!(args[0], "bash");
assert_eq!(args[1], "-c");
assert_eq!(args[2], "echo hi");
}
#[test]
fn shell_host_exec_powershell_uses_command_flag() {
let sh = ShellHost::new(&["pwsh.exe".into()]);
let args = sh.exec_cmd_args("Get-Date");
assert_eq!(args[1], "-Command");
assert_eq!(args[2], "Get-Date");
}
#[test]
fn shell_host_exec_cmd_uses_slash_c() {
let sh = ShellHost::new(&["cmd.exe".into()]);
let args = sh.exec_cmd_args("dir");
assert_eq!(args[1], "/c");
assert_eq!(args[2], "dir");
}
#[test]
fn shell_host_preserves_shell_argv_prefix() {
let sh = ShellHost::new(&["bash".into(), "-l".into(), "-i".into()]);
let args = sh.exec_cmd_args("echo x");
assert_eq!(args[0], "bash");
assert_eq!(args[1], "-c");
assert_eq!(args[2], "echo x");
}
}