package container import ( "encoding/json" "os" "path/filepath" "sync" "forge.lthn.ai/core/cli/pkg/io" ) // State manages persistent container state. type State struct { // Containers is a map of container ID to Container. Containers map[string]*Container `json:"containers"` mu sync.RWMutex filePath string } // DefaultStateDir returns the default directory for state files (~/.core). func DefaultStateDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".core"), nil } // DefaultStatePath returns the default path for the state file. func DefaultStatePath() (string, error) { dir, err := DefaultStateDir() if err != nil { return "", err } return filepath.Join(dir, "containers.json"), nil } // DefaultLogsDir returns the default directory for container logs. func DefaultLogsDir() (string, error) { dir, err := DefaultStateDir() if err != nil { return "", err } return filepath.Join(dir, "logs"), nil } // NewState creates a new State instance. func NewState(filePath string) *State { return &State{ Containers: make(map[string]*Container), filePath: filePath, } } // LoadState loads the state from the given file path. // If the file doesn't exist, returns an empty state. func LoadState(filePath string) (*State, error) { state := NewState(filePath) dataStr, err := io.Local.Read(filePath) if err != nil { if os.IsNotExist(err) { return state, nil } return nil, err } if err := json.Unmarshal([]byte(dataStr), state); err != nil { return nil, err } return state, nil } // SaveState persists the state to the configured file path. func (s *State) SaveState() error { s.mu.RLock() defer s.mu.RUnlock() // Ensure the directory exists dir := filepath.Dir(s.filePath) if err := io.Local.EnsureDir(dir); err != nil { return err } data, err := json.MarshalIndent(s, "", " ") if err != nil { return err } return io.Local.Write(s.filePath, string(data)) } // Add adds a container to the state and persists it. func (s *State) Add(c *Container) error { s.mu.Lock() s.Containers[c.ID] = c s.mu.Unlock() return s.SaveState() } // Get retrieves a copy of a container by ID. // Returns a copy to prevent data races when the container is modified. func (s *State) Get(id string) (*Container, bool) { s.mu.RLock() defer s.mu.RUnlock() c, ok := s.Containers[id] if !ok { return nil, false } // Return a copy to prevent data races copy := *c return ©, true } // Update updates a container in the state and persists it. func (s *State) Update(c *Container) error { s.mu.Lock() s.Containers[c.ID] = c s.mu.Unlock() return s.SaveState() } // Remove removes a container from the state and persists it. func (s *State) Remove(id string) error { s.mu.Lock() delete(s.Containers, id) s.mu.Unlock() return s.SaveState() } // All returns copies of all containers in the state. // Returns copies to prevent data races when containers are modified. func (s *State) All() []*Container { s.mu.RLock() defer s.mu.RUnlock() containers := make([]*Container, 0, len(s.Containers)) for _, c := range s.Containers { copy := *c containers = append(containers, ©) } return containers } // FilePath returns the path to the state file. func (s *State) FilePath() string { return s.filePath } // LogPath returns the log file path for a given container ID. func LogPath(id string) (string, error) { logsDir, err := DefaultLogsDir() if err != nil { return "", err } return filepath.Join(logsDir, id+".log"), nil } // EnsureLogsDir ensures the logs directory exists. func EnsureLogsDir() error { logsDir, err := DefaultLogsDir() if err != nil { return err } return io.Local.EnsureDir(logsDir) }