# Go desde Cero, Pero Bien: Calculadora de Gastos CLI

Go desde Cero, Pero Bien: Calculadora de Gastos CLI
Tabla de Contenidos

Mis Bros! Llegó el momento de la verdad. Ya dominamos las bases de las entradas y salidas, así que es hora de construir algo real, algo útil: nuestra primera aplicación de línea de comandos (CLI).

En este capítulo, vamos a crear una calculadora de gastos. Una herramienta sencilla pero poderosa que nos permitirá aplicar todo lo que hemos aprendido: leer la entrada del usuario, procesar datos, manejar archivos y, lo más importante, estructurar nuestro código como profesionales.

A Programar muchachones!!! Creando Nuestra Primera App de Terminal (CLI)

Nuestra aplicación de gastos tendrá las siguientes características:

  1. Capturar gastos: El usuario podrá introducir gastos uno por uno.
  2. Calcular estadísticas: Obtendremos el total, el promedio, el gasto mínimo y el máximo.
  3. Mostrar un resumen: Imprimiremos en la consola un reporte detallado.
  4. Guardar y leer: Podremos guardar el reporte en un archivo .txt y leerlo de vuelta.

1. La Estructura del Proyecto: Orden ante todo!!!

Una de las cosas esenciales, y que muchos no se toman el tiempo, es la organización, fundamental para buen programador. En lugar de meter todo en un solo archivo main.go, vamos a separar nuestro código por responsabilidades. Así es mucho más fácil de leer, mantener y ampliar en el futuro.

Nuestra estructura de directorios se verá así:

Estructura de carpetas
  • main.go: Contiene el bucle principal de la aplicación y decide qué hacer según la opción del usuario.
  • expenses/expenses.go: Se encarga de la “lógica de negocio”. Sabe cómo capturar gastos, calcular estadísticas e imprimir los detalles. No le importa si los datos vienen de un archivo o de la terminal, solo procesa números.
  • commands/commands.go: Es nuestra capa de “interfaz”. Sabe cómo mostrar un menú, leer la entrada del usuario y cómo interactuar con el sistema de archivos (crear y leer).

2. El Paquete commands: La Interfaz con el Usuario y los Archivos

Este paquete es el puente entre nuestra aplicación y el mundo exterior.

commands/commands.go

package commands
import (
"bufio"
"fmt"
"os"
"strings"
)
// ShowMenu simplemente muestra las opciones disponibles.
func ShowMenu() {
fmt.Println("--- Calculadora de Gastos ---")
fmt.Println("1. Añadir gastos")
fmt.Println("2. Mostrar detalles")
fmt.Println("3. Guardar reporte en archivo")
fmt.Println("4. Leer reporte de archivo")
fmt.Println("0. Salir")
fmt.Println("-----------------------------")
}
// GetUserInput lee una línea de texto del usuario de forma segura.
// Usamos bufio.Scanner porque es más simple y robusto para leer líneas.
func GetUserInput(prompt string) (string, error) {
fmt.Print(prompt)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
return strings.TrimSpace(scanner.Text()), nil
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error leyendo la entrada del usuario: %w", err)
}
// Se llega aquí si el usuario presiona Ctrl+D (EOF)
return "", fmt.Errorf("entrada cancelada")
}
// FileExists comprueba si un archivo existe. Es una función de utilidad muy común.
func FileExists(filename string) bool {
_, err := os.Stat(filename)
// La forma idiomática en Go es usar os.IsNotExist para comprobar esto.
return !os.IsNotExist(err)
}
// ReadFile lee todo el contenido de un archivo. Ahora devuelve un error en lugar de imprimirlo.
// Esto le da el control al que llama (main.go) para decidir qué hacer con el error.
func ReadFile(nameFile string) (string, error) {
if !FileExists(nameFile) {
return "", fmt.Errorf("el archivo '%s' no existe", nameFile)
}
content, err := os.ReadFile(nameFile)
if err != nil {
return "", fmt.Errorf("error al leer el archivo '%s': %w", nameFile, err)
}
return string(content), nil
}
// SaveToFile guarda el contenido en un archivo. También devuelve un error.
// Usamos os.WriteFile que es más simple para este caso.
func SaveToFile(content string, nameFile string) error {
err := os.WriteFile(nameFile, []byte(content), 0644)
if err != nil {
return fmt.Errorf("error al crear el archivo '%s': %w", nameFile, err)
}
return nil
}

Puntos Clave:

  1. Funciones con un Propósito Único: ShowMenu solo muestra el menú. La lógica para obtener la opción está en main. Esto es más limpio.

  2. bufio.Scanner para la Entrada: bufio.Scanner. Es la herramienta recomendada para leer línea por línea, ya que maneja los finales de línea (\n o \r\n) automáticamente y es más eficiente.

  3. Errores con Retorno, no con fmt.Println: Las funciones ReadFile y SaveToFile devuelven un error. Esta es una práctica fundamental en Go. Las funciones de “biblioteca” (como las de este paquete) no deben imprimir errores. Deben devolverlos para que la función que las llama (en nuestro caso, main) decida qué hacer: mostrar un mensaje al usuario, salir del programa, etc. Esto hace nuestro código mucho más reutilizable y flexible.

  4. Nombres Claros: FileExists, que describe exactamente lo que hace.


3. El Paquete expenses: La Lógica de Negocio

Este paquete es el cerebro. No sabe nada de la terminal ni de archivos, solo de números.

expenses/expenses.go

package expenses
import (
"fmt"
"Apps/App_cli_calc_expenses/commands"
"strconv"
"strings"
)
type Stats struct {
Total float64
Average float64
Min float64
Max float64
}
// CaptureExpenses se encarga de recibir los gastos del usuario.
func CaptureExpenses() ([]float64, error) {
var expensesList []float64
fmt.Println("Introduce los gastos uno por uno. Escribe 'fin' para terminar.")
for {
input, err := commands.GetUserInput("Gasto: ")
if err != nil {
return nil, err // Propagamos el error hacia arriba
}
if strings.ToLower(input) == "fin" {
fmt.Println("...volviendo al menú.")
break
}
expense, err := strconv.ParseFloat(input, 64)
if err != nil {
fmt.Println("Valor inválido. Por favor, introduce un número (ej: 25.50).")
continue // Pedimos el siguiente valor
}
expensesList = append(expensesList, expense)
}
return expensesList, nil
}
// GenerateStats calcula todas las estadísticas de una lista de gastos.
func GenerateStats(expenses []float64) (Stats, error) {
if len(expenses) == 0 {
return Stats{}, fmt.Errorf("no hay gastos para calcular estadísticas")
}
var stats Stats
stats.Min = expenses[0] // Inicializamos min y max con el primer valor
stats.Max = expenses[0]
for _, expense := range expenses {
stats.Total += expense
if expense > stats.Max {
stats.Max = expense
}
if expense < stats.Min {
stats.Min = expense
}
}
stats.Average = stats.Total / float64(len(expenses))
return stats, nil
}
// FormatDetails crea el string del reporte final.
func FormatDetails(expenses []float64, stats Stats) string {
var builder strings.Builder
builder.WriteString("--- Reporte de Gastos ---\n")
for i, expense := range expenses {
builder.WriteString(fmt.Sprintf("Gasto #%d: %.2f\n", i+1, expense))
}
builder.WriteString("---------------------------\n")
builder.WriteString(fmt.Sprintf("Total: %.2f\n", stats.Total))
builder.WriteString(fmt.Sprintf("Promedio: %.2f\n", stats.Average))
builder.WriteString(fmt.Sprintf("Mínimo: %.2f\n", stats.Min))
builder.WriteString(fmt.Sprintf("Máximo: %.2f\n", stats.Max))
builder.WriteString("---------------------------\n")
return builder.String()
}

Puntos Clave:

  1. Evitar Bug Crítico: En la función GenerateStats, si inicializaba minExpense en 0. Si todos tus gastos son positivos (ej: 10, 20, 30), el mínimo siempre sería 0, lo cual es incorrecto! La forma correcta es * inicializar min y max con el primer elemento de la lista* y luego recorrer el resto. Muchachones este detalle que marca la diferencia!!!.

  2. Struct para Estadísticas: Creamos un struct Stats para agrupar todos los resultados. Es más limpio que devolver cuatro valores float64 sueltos.

  3. Separación de Responsabilidades: GenerateStats solo calcula y FormatDetails solo formatea.


4. El Director de Orquesta: main.go

Aquí es donde todo se une. main es el responsable del flujo de la aplicación.

main.go (Mejorado)

package main
import (
"fmt"
"Apps/App_cli_calc_expenses/commands"
"Apps/App_cli_calc_expenses/expenses"
)
func main() {
var data []float64
for {
commands.ShowMenu()
choice, err := commands.GetUserInput("Elige una opción: ")
if err != nil {
fmt.Println("Error: ", err)
break
}
switch choice {
case "1":
// Capturamos nuevos gastos. Si hay error, lo mostramos y continuamos.
newExpenses, err := expenses.CaptureExpenses()
if err != nil {
fmt.Printf("No se pudieron capturar los gastos: %v\n", err)
continue
}
data = newExpenses
if len(data) > 0 {
fmt.Println("¡Gastos capturados! Usa la opción 2 para ver los detalles.")
}
case "2":
if len(data) == 0 {
fmt.Println("Aún no has añadido gastos. Usa la opción 1.")
continue
}
stats, err := expenses.GenerateStats(data)
if err != nil {
fmt.Println("Error al generar detalles:", err)
continue
}
fmt.Println(expenses.FormatDetails(data, stats))
case "3":
if len(data) == 0 {
fmt.Println("Nada que guardar. Usa la opción 1 para añadir gastos.")
continue
}
fileName, _ := commands.GetUserInput("Nombre del archivo (ej: mis_gastos.txt): ")
if fileName == "" {
fileName = "gastos.txt" // Nombre por defecto
fmt.Println("Nombre vacío, usando 'gastos.txt'")
}
stats, _ := expenses.GenerateStats(data)
content := expenses.FormatDetails(data, stats)
err := commands.SaveToFile(content, fileName)
if err != nil {
fmt.Printf("Error al guardar el archivo: %v\n", err)
} else {
fmt.Printf("¡Reporte guardado exitosamente en '%s'!\n", fileName)
}
case "4":
fileName, _ := commands.GetUserInput("Nombre del archivo a leer: ")
if fileName == "" {
fmt.Println("Debes ingresar un nombre de archivo.")
continue
}
content, err := commands.ReadFile(fileName)
if err != nil {
fmt.Printf("Error al leer el archivo: %v\n", err)
} else {
fmt.Println("\n--- Contenido de", fileName, "---")
fmt.Println(content)
}
case "0":
fmt.Println("Hasta luego!!! Cerrando la aplicación...")
return // La forma correcta de salir del bucle y terminar main
default:
fmt.Println("Opción no válida. Por favor, intenta de nuevo.")
}
// Una pausa visual antes de volver a mostrar el menú
fmt.Println()
}
}

Puntos Clave:

  1. Flujo Lógico en main: main tiene el control total. Llama a las funciones de los otros paquetes y, lo más importante, maneja los errores que estas devuelven. Muestra mensajes amigables al usuario basados en esos errores.

  2. Lógica sin Estado Innecesario: No utilizamos variables content que actuan como caché. El reporte se genera siempre que se necesita (en los casos “2” y “3”). Esto simplifica el código y evita bugs donde el reporte podría estar desactualizado.

  3. Salida Limpia: Usamos return en el case "0" para salir de la función main y, por lo tanto, del programa. Es más directo que usar break en un bucle infinito.


Tu Turno!!! Ejercicio Propuesto: Gestor de Tareas CLI

Ahora muchachon que ya comprendes como realiar una app de CLI, te toca a ti!!! Crea una aplicación similar para gestionar una lista de tareas (To-Do List).

Requisitos:

  1. Estructura de Paquetes: Utiliza una estructura similar (main, tasks, commands).

  2. Definir Tarea: Una tarea debe tener un ID, una Descripción (string) y un estado Completada (bool). Puedes usar un struct para esto.

  3. Funcionalidades del Menú:

    • Añadir Tarea: Pide al usuario la descripción de una nueva tarea.

    • Listar Tareas: Muestra todas las tareas, indicando con un [X] las completadas y [ ] las pendientes.

    • Marcar Tarea como Completada: Pide al usuario el ID de la tarea y cambia su estado.

    • Guardar Tareas: Guarda la lista actual de tareas en un archivo tasks.json. (Un reto extra!!! Usa el paquete encoding/json que vimos antes).

    • Cargar Tareas: Lee el archivo tasks.json para empezar con la lista guardada.

Este ejercicio te obligará a combinar todo lo que hemos visto: structs, slices, manejo de I/O, separación de código y hasta serialización con JSON. Activitos!!!, hasta el proximo capitulo muchachones.

Foto Cesar Fernandez

¿Lo rompiste? ¿Lo mejoraste?

Gracias por llegar hasta el final. Escribo estos posts para organizar mis propias ideas y, con suerte, para ahorrarle a alguien más el dolor de cabeza que yo ya pasé. Me encuentras en LinkedIn o puedes ver más de mi trabajo en GitHub.


Serie: Curso Profesional de Go