Gitfed
bastien-mrq/sssh / internal / store / store.go
// Package store gère la persistance des hosts dans hosts.toml.
package store

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/pelletier/go-toml/v2"

	"git.neuromancer.ovh/bastien-mrq/sssh/internal/host"
)

type fileFormat struct {
	Hosts map[string]host.Host `toml:"hosts"`
}

// Store est le contenu chargé de hosts.toml.
type Store struct {
	path  string
	Hosts map[string]host.Host
}

// Path rend le chemin du fichier de configuration, surchargé par $SSSH_CONFIG.
func Path() string {
	if p := os.Getenv("SSSH_CONFIG"); p != "" {
		return p
	}
	base, err := os.UserConfigDir()
	if err != nil {
		base = filepath.Join(os.Getenv("HOME"), ".config")
	}
	return filepath.Join(base, "sssh", "hosts.toml")
}

// Load lit hosts.toml ; un fichier absent donne un store vide.
func Load() (*Store, error) {
	s := &Store{path: Path(), Hosts: map[string]host.Host{}}
	data, err := os.ReadFile(s.path)
	if os.IsNotExist(err) {
		return s, nil
	}
	if err != nil {
		return nil, err
	}
	var f fileFormat
	if err := toml.Unmarshal(data, &f); err != nil {
		return nil, fmt.Errorf("%s : %w", s.path, err)
	}
	if f.Hosts != nil {
		s.Hosts = f.Hosts
	}
	return s, nil
}

// Save écrit le fichier de façon atomique (fichier temporaire puis rename).
func (s *Store) Save() error {
	data, err := s.Marshal()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
		return err
	}
	tmp := s.path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o600); err != nil {
		return err
	}
	return os.Rename(tmp, s.path)
}

// Marshal sérialise le store au format hosts.toml.
func (s *Store) Marshal() ([]byte, error) {
	return toml.Marshal(fileFormat{Hosts: s.Hosts})
}

// Get rend le host nommé, avec son Name renseigné.
func (s *Store) Get(name string) (host.Host, bool) {
	h, ok := s.Hosts[name]
	if ok {
		h.Name = name
	}
	return h, ok
}

// Set ajoute ou remplace un host.
func (s *Store) Set(h host.Host) {
	s.Hosts[h.Name] = h
}

// Remove supprime un host ; rend false s'il n'existait pas.
func (s *Store) Remove(name string) bool {
	if _, ok := s.Hosts[name]; !ok {
		return false
	}
	delete(s.Hosts, name)
	return true
}

// List rend les hosts triés par nom.
func (s *Store) List() []host.Host {
	hosts := make([]host.Host, 0, len(s.Hosts))
	for name, h := range s.Hosts {
		h.Name = name
		hosts = append(hosts, h)
	}
	sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name })
	return hosts
}

// Resolve cherche name parmi hosts : correspondance exacte d'abord,
// sinon préfixe unique. En cas d'ambiguïté, l'erreur liste les candidats.
func Resolve(hosts []host.Host, name string) (host.Host, error) {
	var candidates []host.Host
	for _, h := range hosts {
		if h.Name == name {
			return h, nil
		}
		if strings.HasPrefix(h.Name, name) {
			candidates = append(candidates, h)
		}
	}
	switch len(candidates) {
	case 1:
		return candidates[0], nil
	case 0:
		return host.Host{}, fmt.Errorf("host inconnu : %q (voir sssh list)", name)
	default:
		names := make([]string, len(candidates))
		for i, h := range candidates {
			names[i] = h.Name
		}
		return host.Host{}, fmt.Errorf("%q est ambigu : %s", name, strings.Join(names, ", "))
	}
}